diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 193cc4b..2275eb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,10 @@ jobs: # to be swept, and that list only ever shrinks. - run: yarn test:comment-length + # The Webhooks page reads server main's openapi/webhooks.json, and its preview + # mirrors the payload rules. This fails when either drifts from the server. + - run: yarn test:webhook-spec + # `next build` type-checks as part of building and there is no separate # typecheck script, so this is both. - run: yarn build diff --git a/content/docs/build/index.mdx b/content/docs/build/index.mdx index acd6dd7..e740d29 100644 --- a/content/docs/build/index.mdx +++ b/content/docs/build/index.mdx @@ -4,7 +4,7 @@ description: Bots, addons, plugins, and the packages you can build against icon: Blocks --- -Four ways to extend Gryt, and they run in different places. Which one you want +Five ways to extend Gryt, and they run in different places. Which one you want depends on where your code needs to live. ## Pick where your code runs @@ -25,6 +25,11 @@ depends on where your code needs to live. description="Runs in the server, sees messages and members" href="/docs/build/server-plugins" /> + /api/webhooks// +``` + +The token is the whole of the security. Anyone who has the URL can post as the +webhook, so keep it out of public repositories and logs. There's no way to +rotate a token. If a URL leaks, delete the webhook and make a new one. + +## Send a message + +```bash +curl -X POST 'https://gryt.example.com/api/webhooks/WEBHOOK_ID/TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{"text": "Hello from curl"}' +``` + +That's a whole message. `text` takes the same markdown as the chat box. +Add `cards` for something with a title, colour, fields and pictures: + +```bash +curl -X POST 'https://gryt.example.com/api/webhooks/WEBHOOK_ID/TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "display_name": "Backups", + "cards": [{ + "title": "Backup finished", + "description": "All 3 volumes copied in 2m 41s.", + "color": "#3fb27f" + }] + }' +``` + +The server answers once the message is posted: + +```json +{ + "message_id": "7999e063-ccdb-46d3-8cd3-b1decc35dfd6", + "conversation_id": "general", + "warnings": [] +} +``` + +## Try a card + +Edit the JSON and the card on the right redraws. It's drawn with the same +`WebhookCard` component from [`@gryt/ui`](/docs/build/ui) that the app uses, +and the checks under the editor follow the server's rules, so a payload that +passes here passes there. **Copy as curl** gives you a command with a +placeholder URL to swap for yours. + + + +Two things differ from the app. The preview loads pictures straight from the +URLs you type, from your own browser. Gryt doesn't: the server downloads them +once when the message is posted (see [Pictures](#pictures)). And the preview +only does bold, italics, strikethrough, inline code and links. The app's +markdown also does code blocks, lists, emoji and mentions. + +## Payload + +This reference is read from the server's own OpenAPI document, +[`openapi/webhooks.json`](https://github.com/Gryt-chat/server/blob/main/openapi/webhooks.json), +which is generated from the schema the route checks payloads with. A check in +CI fails when this page and that file disagree. + +### Message + + + +A message needs `text`, at least one card, or both. `display_name` and +`avatar_url` only change this one message. The webhook's own name and picture +stay as they are. + +### Card + + + +A card needs at least one of `title`, `description`, `fields`, `image_url` or +`author`. The title, description, author name, footer text and every field's +name and value count towards a limit of 6000 characters, added up across all +the cards in the message. + +Timestamps show in each reader's own time zone. + +### Author + + + +### Field + + + +Inline fields sit side by side as long as there's room. A field that isn't +inline gets a row to itself. + +### Footer + + + +## Pictures + +`avatar_url`, `image_url`, `thumbnail_url` and both `icon_url`s are downloaded +by the Gryt server when the message is posted. It stores a copy and the message +points at that copy. People reading the channel load the picture from your Gryt +server and never contact the host it came from, so that host can't see who +read the message or when. + +That also means the picture is fixed at post time. Changing the file at the +original URL afterwards changes nothing in Gryt. + +- PNG, JPEG, WebP and GIF. SVG is refused. The server checks the file's bytes, + not its name or `Content-Type` +- Up to 8 MB for `image_url` and `thumbnail_url`, 1 MB for `avatar_url` and the + icons, and 32 MB for all the pictures in one message +- 12 seconds to download all of them +- Private and local addresses are refused, and so is a chain of more than 5 + redirects +- The same URL used twice in a message is downloaded once + +A picture that fails doesn't stop the message. It posts without that picture, +and the reply lists what went wrong under `warnings`. Because of the downloads, +a message with pictures can take a few seconds to answer. + +## Mentions + +Only `text` can notify anyone. A mention is a markdown link to `mention:` and +the member's ID: + +```json +{ "text": "Deploy failed, [@Sivert](mention:user_166ad0aa-aef6-494d-8475-9c842fbc29b2) can you look?" } +``` + +To get an ID, right-click the member in the member list and pick **Copy ID**. + +Mentions inside cards draw like mentions but never notify, so a card listing +ten commit authors doesn't ping all ten. + +## Older clients + +A client that can't draw cards shows a single line instead. When a message has +cards and no `text`, the server writes that line itself: the first card's +title, or its author's name if it has no title, plus a count like "(+2 more)" +when there are more cards. When the first card has neither, it says "Posted a +card", or "Posted 3 cards". + +Mentions and links are broken on purpose in that line, so it can't ping anyone +or unfurl. Clients that do draw cards hide it. If you send `text` as well, +older clients show your `text` and nothing else. + +## When it goes wrong + +A refused payload posts nothing. Every problem comes back at once, up to 20, +each with a `path` into your JSON: + +```json +{ + "error": "invalid_payload", + "message": "The payload has 3 problems.", + "problems": [ + { "path": "cards[0].color", "code": "invalid_color", "message": "Use #rrggbb or an integer from 0 to 16777215." }, + { "path": "cards[0].timestamp", "code": "invalid_timestamp", "message": "Invalid ISO datetime" }, + { "path": "cards[0]", "code": "empty_card", "message": "A card needs a title, description, fields, image_url or author." } + ] +} +``` + +`limit` is there too when the problem is a limit. Match on `code`, since +`message` is for people and can change. + + + +Some answers have no `problems` list. The first two are from before cards +existed, and they come back instead of `invalid_payload` when they're the only +problem: + +| Status | Body | When | +|---|---|---| +| 400 | `{"error": "empty_message"}` | No `text` (or only spaces) and no cards | +| 400 | `{"error": "message_too_long"}` | `text` is over 4,000 characters | +| 400 | `{"error": "no_channel"}` | The webhook has no channel set | +| 404 | `{"error": "not_found"}` | No webhook with that ID and token | +| 429 | `{"error": "rate_limited", "retry_after_ms": 59988}` | See [Rate limits](#rate-limits) | + + +JSON that doesn't parse gets a `500` with `"error": "internal_error"` and the +parser's message today. So does a body over 2 MB. Both should really be 4xx +answers and will probably change. Don't match on them. Treat a 5xx as "check +the request, then try again later". + + +### Warnings + +A message that posts can still come back with `warnings`: + +```json +{ + "message_id": "7999e063-ccdb-46d3-8cd3-b1decc35dfd6", + "conversation_id": "general", + "warnings": [ + { "path": "embeds", "code": "unknown_key", "message": "embeds isn't part of a webhook message and was ignored." }, + { "path": "cards[0].image_url", "code": "blocked", "message": "That address isn't allowed, so the picture was left out." } + ] +} +``` + +Keys Gryt doesn't know are ignored rather than refused, so a payload written for +another chat app mostly works. Log `warnings` from anything automated, or a +missing picture goes unnoticed. + + + +## Rate limits + +Each webhook gets a burst of 15 requests, then about one every 2 seconds, or 30 +a minute. Go over and that webhook gets `429` for 60 seconds, with +`retry_after_ms` saying how long is left. + +It counts every request to the webhook's URL, including refused ones, and it's +per webhook rather than per caller, so two scripts sharing a URL share the +limit. The counter lives in the server's memory and resets when it restarts. +[Rate limiting](/docs/host/rate-limiting) has the server's other limits. + +## Recipes + +### Deploy notice from GitHub Actions + +Save the webhook URL as a repository secret called `GRYT_WEBHOOK_URL`, then add +a step at the end of the deploy job: + +```yaml +- name: Tell Gryt + if: always() + env: + GRYT_WEBHOOK_URL: ${{ secrets.GRYT_WEBHOOK_URL }} + STATUS: ${{ job.status }} + run: | + if [ "$STATUS" = "success" ]; then color="#3fb27f"; else color="#e5484d"; fi + jq -n \ + --arg status "$STATUS" --arg color "$color" \ + --arg repo "$GITHUB_REPOSITORY" --arg sha "${GITHUB_SHA::7}" \ + --arg actor "$GITHUB_ACTOR" \ + --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + '{ + display_name: "Deploys", + cards: [{ + title: "Deploy \($status): \($repo)", + url: $url, + color: $color, + fields: [ + { name: "Commit", value: $sha, inline: true }, + { name: "By", value: $actor, inline: true } + ], + timestamp: (now | todate) + }] + }' | + curl -fsS -X POST "$GRYT_WEBHOOK_URL" \ + -H 'Content-Type: application/json' --data-binary @- +``` + +`jq` is already on GitHub's runners. It builds the JSON for you, so a quote in +a name can't break the payload. + +### Uptime alert from cron + +A script that checks a URL and posts only when the answer changes, so a site +that's down all night sends two messages instead of 500: + +```bash +#!/bin/sh +# Run from cron every minute: * * * * * /usr/local/bin/check-api.sh +WEBHOOK_URL="https://gryt.example.com/api/webhooks/WEBHOOK_ID/TOKEN" +TARGET="https://api.example.com/health" +STATE=/var/tmp/check-api.state + +code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$TARGET") +[ "$code" = "200" ] && now=up || now=down +[ "$(cat "$STATE" 2>/dev/null)" = "$now" ] && exit 0 +echo "$now" > "$STATE" + +if [ "$now" = "down" ]; then + title="$TARGET is down"; color="#e5484d" +else + title="$TARGET is back"; color="#3fb27f" +fi + +jq -n --arg title "$title" --arg color "$color" --arg code "$code" '{ + display_name: "Uptime", + cards: [{ + title: $title, + color: $color, + fields: [{ name: "Status", value: $code, inline: true }], + timestamp: (now | todate) + }] +}' | curl -fsS -X POST "$WEBHOOK_URL" -H 'Content-Type: application/json' --data-binary @- +``` + +### Plain text from any script + +No `jq`, no cards. Fine for a quick "it's done": + +```bash +curl -fsS -X POST "$WEBHOOK_URL" \ + -H 'Content-Type: application/json' \ + -d "{\"text\": \"Backup on $(hostname) finished at $(date -u +%H:%M) UTC\"}" +``` + +Keep anything you didn't write yourself out of a string like that. A file name +with a quote in it breaks the JSON. Use `jq -n --arg` like the recipes above +when the text comes from somewhere else. + +## Managing webhooks over the API + +The Webhooks tab uses these, and so can you, with a member's access token that +has **Manage webhooks**. They're listed in [Server API](/docs/build/server-api#webhooks), +and the request and response shapes are in the same +[`openapi/webhooks.json`](https://github.com/Gryt-chat/server/blob/main/openapi/webhooks.json). diff --git a/package.json b/package.json index e1b5b76..941e941 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,11 @@ "dev": "next dev --turbo", "start": "next start", "test:comment-length": "node scripts/check-comment-length.mjs", + "test:webhook-spec": "node scripts/check-webhook-spec.mjs", "postinstall": "fumadocs-mdx" }, "dependencies": { - "@gryt/ui": "^0.32.0", + "@gryt/ui": "^0.34.0", "fumadocs-core": "^16.15.10", "fumadocs-mdx": "^15.4.0", "fumadocs-mermaid": "^1.3.0", @@ -33,6 +34,7 @@ "eslint-config-next": "15.5.2", "postcss": "^8.5.6", "tailwindcss": "^4.1.13", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "zod": "^4.6.5" } } diff --git a/public/webhooks/create-webhook.png b/public/webhooks/create-webhook.png new file mode 100644 index 0000000..cda0c37 Binary files /dev/null and b/public/webhooks/create-webhook.png differ diff --git a/public/webhooks/sample-icon.png b/public/webhooks/sample-icon.png new file mode 100644 index 0000000..31f23ad Binary files /dev/null and b/public/webhooks/sample-icon.png differ diff --git a/public/webhooks/sample-image.jpg b/public/webhooks/sample-image.jpg new file mode 100644 index 0000000..f8d30e8 Binary files /dev/null and b/public/webhooks/sample-image.jpg differ diff --git a/public/webhooks/sample-thumb.jpg b/public/webhooks/sample-thumb.jpg new file mode 100644 index 0000000..5da4c35 Binary files /dev/null and b/public/webhooks/sample-thumb.jpg differ diff --git a/scripts/check-webhook-spec.mjs b/scripts/check-webhook-spec.mjs new file mode 100644 index 0000000..4fe15f8 --- /dev/null +++ b/scripts/check-webhook-spec.mjs @@ -0,0 +1,147 @@ +// Fails when the Webhooks page drifts from Gryt-chat/server: the vendored spec, the limits, or the preview's validation. +// Reads server main over HTTPS, or a local checkout from GRYT_SERVER_DIR. + +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { isDeepStrictEqual } from "node:util"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const SPEC = "src/components/webhooks/openapi.json"; +const RAW = "https://raw.githubusercontent.com/Gryt-chat/server/main"; + +async function serverFile(path) { + if (process.env.GRYT_SERVER_DIR) return readFileSync(join(process.env.GRYT_SERVER_DIR, path), "utf8"); + const res = await fetch(`${RAW}/${path}`); + if (!res.ok) throw new Error(`${RAW}/${path} answered ${res.status}`); + return res.text(); +} + +const failures = []; +const fail = (msg) => failures.push(msg); + +const [specText, schemasTs, limitsTs] = await Promise.all([ + serverFile("openapi/webhooks.json"), + serverFile("src/routes/webhookSchemas.ts"), + serverFile("src/utils/messageLimits.ts"), +]); + +if (!isDeepStrictEqual(JSON.parse(specText), JSON.parse(readFileSync(join(root, SPEC), "utf8")))) { + fail(`${SPEC} differs from openapi/webhooks.json on server main. Copy it over and check the page still says the same.`); +} + +// Node won't strip types under node_modules, so the server source sits in a temp dir, pointed at our zod. +const work = mkdtempSync(join(tmpdir(), "gryt-webhook-check-")); +process.on("exit", () => rmSync(work, { recursive: true, force: true })); +writeFileSync(join(work, "messageLimits.ts"), limitsTs); +writeFileSync( + join(work, "webhookSchemas.ts"), + schemasTs + .replace(/from "\.\.\/utils\/messageLimits"/, 'from "./messageLimits.ts"') + .replace(/from "zod"/, `from ${JSON.stringify(import.meta.resolve("zod"))}`), +); + +const server = await import(pathToFileURL(join(work, "webhookSchemas.ts")).href); +const { MESSAGE_MAX_LENGTH } = await import(pathToFileURL(join(work, "messageLimits.ts")).href); +const docs = await import(pathToFileURL(join(root, "src/components/webhooks/validate.ts")).href); +const { WEBHOOK_PRESETS } = await import(pathToFileURL(join(root, "src/components/webhooks/presets.ts")).href); +const { PROBLEM_CODES, WARNING_CODES } = await import(pathToFileURL(join(root, "src/components/webhooks/codes.ts")).href); + +const spec = JSON.parse(specText).components.schemas; +const listed = { + problem: [spec.InvalidPayload.properties.problems.items.properties.code.enum, PROBLEM_CODES], + warning: [spec.WebhookMessageSent.properties.warnings.items.properties.code.enum, WARNING_CODES], +}; +for (const [kind, [codes, words]] of Object.entries(listed)) { + const missing = codes.filter((code) => !words[code]); + const extra = Object.keys(words).filter((code) => !codes.includes(code)); + if (missing.length) fail(`codes.ts has no words for the ${kind} code(s) ${missing.join(", ")}.`); + if (extra.length) fail(`codes.ts explains ${kind} code(s) the spec doesn't have: ${extra.join(", ")}.`); +} + +if (!isDeepStrictEqual({ ...server.WEBHOOK_LIMITS, text: MESSAGE_MAX_LENGTH }, { ...docs.WEBHOOK_LIMITS })) { + fail(`WEBHOOK_LIMITS in validate.ts don't match the server's: ${JSON.stringify(server.WEBHOOK_LIMITS)}, text ${MESSAGE_MAX_LENGTH}`); +} + +/** The route's answer, as src/routes/webhooks.ts builds it. */ +function serverVerdict(body) { + const parsed = server.webhookMessageSchema.safeParse(body ?? {}, { reportInput: true }); + if (parsed.success) return { ok: true, warnings: server.unknownKeys(body).map(({ path, code }) => ({ path, code })) }; + const problems = server.problemsFrom(parsed.error); + if (problems.length === 1 && problems[0].code === "empty_message") return { ok: false, error: "empty_message" }; + if (problems.length === 1 && problems[0].path === "text" && problems[0].code === "too_long") return { ok: false, error: "message_too_long" }; + return { ok: false, error: "invalid_payload", problems: problems.map(({ path, code, limit }) => ({ path, code, limit })) }; +} + +function docsVerdict(body) { + const v = docs.checkWebhookPayload(body); + if (v.ok) return { ok: true, warnings: v.warnings.map(({ path, code }) => ({ path, code })) }; + if (v.body.error !== "invalid_payload") return { ok: false, error: v.body.error }; + return { ok: false, error: "invalid_payload", problems: v.body.problems.map(({ path, code, limit }) => ({ path, code, limit })) }; +} + +// Seeded, so a failure names a case that reproduces. +let seed = 1187; +const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648); +const pick = (list) => list[Math.floor(rand() * list.length)]; + +const ODD_VALUES = [ + undefined, null, true, 0, 1.5, -1, 16777215, 16777216, "", " ", "x", " padded ", "#3fb27f", "#3FB27G", "red", + "https://example.com/a.png", " https://example.com/b.png ", "http:example.com", "ftp://example.com", "https://", + `https://example.com/${"a".repeat(2048)}`, "2026-09-15T07:42:00Z", "2026-09-15T07:42:00+02:00", "2026-09-15", + "2026-02-30T00:00:00Z", "a".repeat(257), "b".repeat(1025), "c".repeat(4001), [], {}, [{}], { name: "n" }, + { name: "n", value: "v" }, { name: " ", value: "v", inline: "yes" }, { text: "footer", icon_url: "nope" }, +]; + +function mutate(value, depth = 0) { + if (Array.isArray(value)) { + const copy = value.map((v) => (rand() < 0.3 ? mutate(v, depth + 1) : v)); + if (rand() < 0.15) copy.push(copy.length ? structuredClone(pick(copy)) : pick(ODD_VALUES)); + if (rand() < 0.1 && copy.length) copy.splice(Math.floor(rand() * copy.length), 1); + if (rand() < 0.05) while (copy.length && copy.length < 30) copy.push(structuredClone(copy[0])); + return copy; + } + if (value && typeof value === "object") { + const copy = { ...value }; + for (const key of Object.keys(copy)) { + const r = rand(); + if (r < 0.1) delete copy[key]; + else if (r < 0.25) copy[key] = pick(ODD_VALUES); + else if (r < 0.5) copy[key] = mutate(copy[key], depth + 1); + } + if (rand() < 0.1) copy[pick(["extra", "embeds", "username", "inline", "name", "text"])] = pick(ODD_VALUES); + return copy; + } + return rand() < 0.3 ? pick(ODD_VALUES) : value; +} + +const cases = [ + ...WEBHOOK_PRESETS.map((p) => p.payload), + {}, [], { text: " " }, { text: "c".repeat(4001) }, { text: "c".repeat(4001), cards: [{}] }, + { cards: [] }, { cards: Array.from({ length: 11 }, () => ({ title: "t" })) }, + { cards: Array.from({ length: 2 }, () => ({ description: "d".repeat(3001) })) }, + { cards: [{ description: "d".repeat(4000), fields: [{ name: "n".repeat(256), value: "v".repeat(1024) }, { name: "n".repeat(256), value: "v".repeat(1000) }] }] }, +]; +for (let i = 0; i < 3000; i++) cases.push(mutate(structuredClone(pick(WEBHOOK_PRESETS).payload))); + +for (const preset of WEBHOOK_PRESETS) { + if (!docsVerdict(preset.payload).ok) fail(`The "${preset.id}" preset doesn't pass validation.`); +} + +let mismatches = 0; +for (const body of cases) { + const want = serverVerdict(body); + const got = docsVerdict(body); + if (isDeepStrictEqual(want, got)) continue; + if (++mismatches <= 5) { + fail(`Validation differs for ${JSON.stringify(body).slice(0, 300)}\n server: ${JSON.stringify(want).slice(0, 600)}\n docs: ${JSON.stringify(got).slice(0, 600)}`); + } +} +if (mismatches > 5) fail(`...and ${mismatches - 5} more payloads where validation differs.`); + +if (failures.length) { + console.error(`check-webhook-spec: ${failures.length} problem(s)\n\n${failures.join("\n\n")}`); + process.exit(1); +} +console.log(`check-webhook-spec: spec and limits match server main, and ${cases.length} payloads validate the same way.`); diff --git a/source.config.ts b/source.config.ts index 36d649d..d0c3d50 100644 --- a/source.config.ts +++ b/source.config.ts @@ -1,6 +1,7 @@ import { defineConfig, defineDocs } from 'fumadocs-mdx/config'; import { metaSchema, pageSchema } from 'fumadocs-core/source/schema'; import { remarkMdxMermaid } from 'fumadocs-mermaid'; +import { markdownTable } from './src/components/webhooks/spec'; export const docs = defineDocs({ docs: { @@ -11,7 +12,17 @@ export const docs = defineDocs({ headingIds: false, // remarkMdxMermaid turned the fence into ; a model reads the fence better. stringify(node) { - if (node.type !== 'mdxJsxFlowElement' || node.name !== 'Mermaid') return; + if (node.type !== 'mdxJsxFlowElement') return; + const attrs = Object.fromEntries( + node.attributes.flatMap((attr) => + attr.type === 'mdxJsxAttribute' && typeof attr.value === 'string' ? [[attr.name, attr.value]] : [], + ), + ); + // The Webhooks page's tables come from the server's spec; the preview only exists on the web page. + if (node.name === 'WebhookPreview') return '_The interactive card preview is on the web version of this page._'; + const table = node.name ? markdownTable(node.name, attrs) : undefined; + if (table) return table; + if (node.name !== 'Mermaid') return; const chart = node.attributes.find( (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'chart', ); diff --git a/src/app/global.css b/src/app/global.css index c247e50..fbce34f 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -5,6 +5,11 @@ /* Gryt's tokens, unprocessed. Dark on :root, and .light swaps the scales. */ @import '@gryt/ui/theme.css'; +/* The one @gryt/ui component the docs draw, on the Webhooks page. Its focus ring lives in a shared + file full of other components' classes, so that one is listed instead of scanned. */ +@source '../../node_modules/@gryt/ui/dist/components/WebhookCard'; +@source inline('focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gryt-accent-light'); + @theme { --font-sans: var(--font-atkinson), sans-serif; --font-mono: var(--font-atkinson-mono), monospace; diff --git a/src/components/webhooks/codes.ts b/src/components/webhooks/codes.ts new file mode 100644 index 0000000..243542b --- /dev/null +++ b/src/components/webhooks/codes.ts @@ -0,0 +1,27 @@ +// What each code in openapi.json means, in words. The spec check fails when a code has no line here. + +export const PROBLEM_CODES: Record = { + required: "Missing, or empty once spaces are trimmed off.", + wrong_type: "The wrong kind of value, like a number where text belongs.", + too_long: "Over the character limit in `limit`.", + too_many: "More cards or fields than `limit` allows.", + invalid_url: "Not an http or https URL.", + invalid_color: "Not `#rrggbb`, and not a whole number from 0 to 16777215.", + invalid_timestamp: "Not ISO 8601 with a time zone. `2026-09-15T07:42:00Z` works, `2026-09-15` doesn't.", + empty_card: "The card has no title, description, fields, image_url or author.", + total_too_long: "All the cards' text together is over 6000 characters.", + invalid_format: "A string in a shape the server doesn't accept.", + invalid: "Anything else the server refused.", +}; + +export const WARNING_CODES: Record = { + unknown_key: "A key Gryt doesn't know. It was ignored and the rest posted.", + blocked: "The picture's address is private or local, or it redirected more than 5 times.", + fetch_failed: "The picture's host answered with an error, or couldn't be reached.", + too_large: "Over 8 MB for an image or thumbnail, or over 1 MB for an icon or avatar.", + timeout: "The pictures weren't all downloaded within 12 seconds.", + unsupported_type: "Not a PNG, JPEG, WebP or GIF. SVG is refused. The server looks at the bytes, not the file name.", + invalid_image: "The file claimed to be an image but couldn't be decoded.", + media_budget: "The message's pictures went over 32 MB together.", + store_failed: "The server couldn't save the picture.", +}; diff --git a/src/components/webhooks/inline-markdown.tsx b/src/components/webhooks/inline-markdown.tsx new file mode 100644 index 0000000..6c400fb --- /dev/null +++ b/src/components/webhooks/inline-markdown.tsx @@ -0,0 +1,48 @@ +import { Fragment, type ReactNode } from 'react'; + +// Bold, italics, strikethrough, inline code and links. The app's chat markdown does more; this covers what cards mostly use. + +const TOKEN = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*)|(_[^_\n]+_)|(\[[^\]\n]+\]\((https?:\/\/[^)\s]+)\))/; + +function inline(text: string, key: string): ReactNode[] { + const out: ReactNode[] = []; + let rest = text; + let n = 0; + while (rest) { + const m = TOKEN.exec(rest); + if (!m) { + out.push(rest); + break; + } + if (m.index > 0) out.push(rest.slice(0, m.index)); + const [whole] = m; + const k = `${key}-${n++}`; + if (m[1]) out.push({whole.slice(1, -1)}); + else if (m[2] || m[3]) out.push({inline(whole.slice(2, -2), k)}); + else if (m[4]) out.push({inline(whole.slice(2, -2), k)}); + else if (m[5] || m[6]) out.push({inline(whole.slice(1, -1), k)}); + else if (m[7]) { + const label = whole.slice(1, whole.indexOf('](')); + out.push( + + {label} + , + ); + } + rest = rest.slice(m.index + whole.length); + } + return out; +} + +export function renderInlineMarkdown(text: string): ReactNode { + return ( + + {text.split('\n').map((line, i) => ( + + {i > 0 ? '\n' : null} + {inline(line, String(i))} + + ))} + + ); +} diff --git a/src/components/webhooks/openapi.json b/src/components/webhooks/openapi.json new file mode 100644 index 0000000..d98bea2 --- /dev/null +++ b/src/components/webhooks/openapi.json @@ -0,0 +1,775 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Gryt webhooks", + "version": "1", + "description": "Post messages into a Gryt channel from anywhere that can send an HTTP request, and manage the webhooks that do it. Every path is relative to your Gryt server.", + "license": { + "name": "AGPL-3.0-or-later", + "identifier": "AGPL-3.0-or-later" + } + }, + "servers": [ + { + "url": "https://{server}", + "variables": { + "server": { + "default": "gryt.example.com", + "description": "Your Gryt server's address." + } + } + } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "A member's access token. Needs the manage_webhooks permission." + } + }, + "schemas": { + "WebhookMessage": { + "type": "object", + "properties": { + "text": { + "description": "Markdown. Required unless there are cards. The only part of a message whose mentions notify.", + "type": "string", + "maxLength": 4000 + }, + "display_name": { + "description": "Posts under this name instead of the webhook's. Cut to 64 characters.", + "type": "string" + }, + "avatar_url": { + "description": "Posts with this picture instead of the webhook's. Fetched once and stored, same as card images.", + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "cards": { + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "description": "Plain text.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "url": { + "description": "Makes the title a link.", + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "description": { + "description": "Markdown. Mentions don't notify anyone.", + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "color": { + "anyOf": [ + { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + { + "type": "integer", + "minimum": 0, + "maximum": 16777215 + } + ], + "description": "The card's colour, as `#rrggbb` or an integer from 0 to 16777215." + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Plain text." + }, + "url": { + "type": "string", + "maxLength": 2048, + "format": "uri", + "description": "An http or https URL." + }, + "icon_url": { + "type": "string", + "maxLength": 2048, + "format": "uri", + "description": "An http or https URL to a PNG, JPEG, WebP or GIF. The server fetches it once, when the message is posted, and stores a copy. Private and local addresses are refused." + } + }, + "required": [ + "name" + ] + }, + "fields": { + "maxItems": 25, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Plain text." + }, + "value": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Markdown." + }, + "inline": { + "default": false, + "description": "Inline fields share a row.", + "type": "boolean" + } + }, + "required": [ + "name", + "value" + ] + } + }, + "image_url": { + "type": "string", + "maxLength": 2048, + "format": "uri", + "description": "An http or https URL to a PNG, JPEG, WebP or GIF. The server fetches it once, when the message is posted, and stores a copy. Private and local addresses are refused." + }, + "thumbnail_url": { + "type": "string", + "maxLength": 2048, + "format": "uri", + "description": "An http or https URL to a PNG, JPEG, WebP or GIF. The server fetches it once, when the message is posted, and stores a copy. Private and local addresses are refused." + }, + "footer": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Plain text." + }, + "icon_url": { + "type": "string", + "maxLength": 2048, + "format": "uri", + "description": "An http or https URL to a PNG, JPEG, WebP or GIF. The server fetches it once, when the message is posted, and stores a copy. Private and local addresses are refused." + } + }, + "required": [ + "text" + ] + }, + "timestamp": { + "description": "ISO 8601, with a time zone.", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + } + } + } + } + } + }, + "WebhookCreate": { + "type": "object", + "properties": { + "channel_id": { + "default": "", + "description": "The channel the webhook posts to.", + "type": "string" + }, + "display_name": { + "description": "Cut to 64 characters. Defaults to \"Webhook\".", + "type": "string" + }, + "avatar_file_id": { + "description": "An uploaded file you can read.", + "type": [ + "string", + "null" + ] + } + } + }, + "WebhookUpdate": { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "display_name": { + "description": "Cut to 64 characters.", + "type": "string" + }, + "avatar_file_id": { + "description": "An uploaded file you can read, or null to remove it.", + "type": [ + "string", + "null" + ] + } + } + }, + "WebhookMessageSent": { + "type": "object", + "properties": { + "message_id": { + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "code": { + "type": "string", + "enum": [ + "unknown_key", + "blocked", + "fetch_failed", + "too_large", + "timeout", + "unsupported_type", + "invalid_image", + "media_budget", + "store_failed" + ], + "description": "`unknown_key` is a key that was ignored. Every other code is a picture that was left out." + }, + "message": { + "type": "string" + } + }, + "required": [ + "path", + "code", + "message" + ] + } + } + }, + "required": [ + "message_id", + "conversation_id", + "warnings" + ] + }, + "InvalidPayload": { + "type": "object", + "properties": { + "error": { + "type": "string", + "const": "invalid_payload" + }, + "message": { + "type": "string" + }, + "problems": { + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Where in the payload, like `cards[0].fields[3].value`." + }, + "code": { + "type": "string", + "enum": [ + "required", + "wrong_type", + "too_long", + "too_many", + "invalid_url", + "invalid_color", + "invalid_timestamp", + "empty_card", + "total_too_long", + "invalid_format", + "invalid" + ] + }, + "limit": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "message": { + "type": "string" + } + }, + "required": [ + "path", + "code", + "message" + ] + } + } + }, + "required": [ + "error", + "message", + "problems" + ] + }, + "Error": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + }, + "Webhook": { + "type": "object", + "properties": { + "webhook_id": { + "type": "string" + }, + "channel_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "avatar_file_id": { + "type": [ + "string", + "null" + ] + }, + "token": { + "type": "string" + }, + "created_by_server_user_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "webhook_id", + "channel_id", + "display_name", + "avatar_file_id", + "token", + "created_by_server_user_id", + "created_at", + "updated_at" + ] + } + } + }, + "paths": { + "/api/webhooks/{webhookId}/{token}": { + "post": { + "operationId": "executeWebhook", + "summary": "Post a message", + "security": [], + "description": "Posts text, cards or both into the webhook's channel. Pictures are downloaded by the server before it answers, so the call can take a few seconds. A picture that fails is left out and listed under `warnings`, and the message still posts. Limited to 30 messages a minute per webhook.", + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookMessage" + } + } + } + }, + "responses": { + "200": { + "description": "Posted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookMessageSent" + } + } + } + }, + "400": { + "description": "The payload was refused, or the webhook has no channel. Nothing was posted.", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidPayload" + }, + { + "$ref": "#/components/schemas/Error" + } + ] + } + } + } + }, + "404": { + "description": "No webhook with that id and token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "The body is over 256 KB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many messages. Try again after `retry_after_ms`.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "const": "rate_limited" + }, + "retry_after_ms": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "/api/webhooks": { + "get": { + "operationId": "listWebhooks", + "summary": "List webhooks", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "The webhooks in channels you can see.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Webhook" + } + } + } + } + } + } + }, + "403": { + "description": "No manage_webhooks permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "createWebhook", + "summary": "Create a webhook", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Created. `url` is what to post to.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Webhook" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + } + ] + } + } + } + }, + "400": { + "description": "The body was refused.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidPayload" + } + } + } + }, + "403": { + "description": "No manage_webhooks permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "The avatar file doesn't exist or you can't read it.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/webhooks/{webhookId}": { + "get": { + "operationId": "getWebhook", + "summary": "Get a webhook", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The webhook.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Webhook" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + } + ] + } + } + } + }, + "404": { + "description": "No such webhook.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "updateWebhook", + "summary": "Update a webhook", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Webhook" + } + } + } + }, + "400": { + "description": "The body was refused.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidPayload" + } + } + } + }, + "404": { + "description": "No such webhook, or an avatar file you can't read.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "deleteWebhook", + "summary": "Delete a webhook", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deleted.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "const": true + } + } + } + } + } + }, + "404": { + "description": "No such webhook.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + } +} diff --git a/src/components/webhooks/presets.ts b/src/components/webhooks/presets.ts new file mode 100644 index 0000000..a27d541 --- /dev/null +++ b/src/components/webhooks/presets.ts @@ -0,0 +1,104 @@ +// Starting payloads for the preview on the Webhooks page. Every one of them has to pass the server's rules. + +const ASSETS = "https://docs.gryt.chat/webhooks"; + +export interface WebhookPreset { + id: string; + label: string; + payload: Record; +} + +export const WEBHOOK_PRESETS: WebhookPreset[] = [ + { + id: "minimal", + label: "Minimal", + payload: { + cards: [ + { + title: "Backup finished", + description: "All 3 volumes copied in 2m 41s.", + color: "#3fb27f", + }, + ], + }, + }, + { + id: "rich", + label: "Rich", + payload: { + text: "api v2.14.0 is live.", + display_name: "Deploys", + avatar_url: `${ASSETS}/sample-icon.png`, + cards: [ + { + author: { name: "ci.example.com", url: "https://example.com/runner", icon_url: `${ASSETS}/sample-icon.png` }, + title: "Deploy finished: api v2.14.0", + url: "https://example.com/deploys/2140", + description: "Rolled out to **eu-north** and **us-east** in 4 minutes.\nTwo migrations ran, and `0047_threads` took the longest.", + color: "#3fb27f", + fields: [ + { name: "Environment", value: "production", inline: true }, + { name: "Commit", value: "a1b2c3d", inline: true }, + { name: "Duration", value: "4m 12s", inline: true }, + ], + thumbnail_url: `${ASSETS}/sample-thumb.jpg`, + image_url: `${ASSETS}/sample-image.jpg`, + footer: { text: "ci.example.com", icon_url: `${ASSETS}/sample-icon.png` }, + timestamp: "2026-09-15T07:42:00Z", + }, + ], + }, + }, + { + id: "fields", + label: "Lots of fields", + payload: { + display_name: "Nightly report", + cards: [ + { + title: "Nightly build, 15 September", + url: "https://example.com/builds/nightly", + color: 5793266, + fields: [ + { name: "Linux", value: "passed", inline: true }, + { name: "macOS", value: "passed", inline: true }, + { name: "Windows", value: "passed", inline: true }, + { name: "Android", value: "passed", inline: true }, + { name: "iOS", value: "1 flaky test", inline: true }, + { name: "Web", value: "passed", inline: true }, + { name: "Tests", value: "4,812 run, 0 failed, 1 retried" }, + { name: "Slowest", value: "`voice-reconnect` at 48 s" }, + ], + footer: { text: "Built from main" }, + timestamp: "2026-09-15T03:00:00Z", + }, + ], + }, + }, + { + id: "alert", + label: "Alert", + payload: { + text: "The API is down.", + display_name: "Uptime", + cards: [ + { + author: { name: "status.example.com", url: "https://example.com/status" }, + title: "api.example.com is not responding", + url: "https://example.com/status/api", + description: "3 checks in a row failed from 2 regions.", + color: "#e5484d", + fields: [ + { name: "Status", value: "503", inline: true }, + { name: "Down for", value: "4 minutes", inline: true }, + { name: "Last healthy", value: "07:38 UTC", inline: true }, + ], + footer: { text: "Checked every 60 s" }, + timestamp: "2026-09-15T07:42:00Z", + }, + ], + }, + }, +]; + +export const PLACEHOLDER_WEBHOOK_URL = "https://gryt.example.com/api/webhooks/WEBHOOK_ID/TOKEN"; diff --git a/src/components/webhooks/preview-loader.tsx b/src/components/webhooks/preview-loader.tsx new file mode 100644 index 0000000..823143f --- /dev/null +++ b/src/components/webhooks/preview-loader.tsx @@ -0,0 +1,8 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +// Every docs page shares one route, so a plain import would ship the preview and its card to all of them. +export const WebhookPreview = dynamic(() => import('./preview').then((m) => m.WebhookPreview), { + loading: () =>