Skip to content

feat(plugins): add the webhooks plugin - #263

Merged
jouwdan merged 4 commits into
mainfrom
claude/mei-115-xt6308-webhooks
Aug 26, 2026
Merged

feat(plugins): add the webhooks plugin#263
jouwdan merged 4 commits into
mainfrom
claude/mei-115-xt6308-webhooks

Conversation

@jouwdan

@jouwdan jouwdan commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Part of MEI-115 — the first of the approved first-cohort plugins.

Stacked on #262. The base branch is claude/mei-115-xt6308-hook-runtime, not main: this plugin is the thing that motivated that change, and cannot work without it. Merge #262 first and this retargets to main cleanly.

What it does

New threads and replies, posted to an endpoint of your choosing. A Discord channel webhook URL works as-is; anything else can take plain, signed JSON.

A thread.created / post.created hook writes one row into the plugin's own queue and returns. A task drains that queue every minute. Nothing is sent from the request itself, so a slow or unreachable endpoint never delays a member's post — which is the whole reason the hook needs runtime access rather than doing the HTTP call inline.

Decisions worth reviewing

  • Retries distinguish "not yet" from "never". A timeout, refused connection, 5xx, 408 or 429 backs off from 30s, doubling to an hour, over six attempts. Any other 4xx is given up on immediately: a 404 from a deleted Discord webhook will not be fixed by sending it again, and retrying it 6 times just delays the operator finding out.
  • Plain-JSON deliveries are signedHMAC-SHA256(secret, "<timestamp>.<body>") over the raw body, with the timestamp bound into the signature so a captured delivery cannot be replayed. Verified in constant time. The secret has an env override, so it need not live in the database.
  • A plain-http:// endpoint is refused rather than sending the board's content over it — surfaced on the status page as its own message, not silently dropped.
  • The payloads carry no English of ours. A Discord embed uses the thread's own subject and link; the JSON body is events and ids. So the plugin adds nothing to the translation surface, and i18n:check stays clean without a baseline entry (unlike plugins/dues, which banked nine).

Validated

  • pnpm verify fully green: 7997 tests, 457 files — 49 of them new here.
  • The delivery runner is tested end-to-end against a fake queue and a fake fetch: delivered, retried-with-reason, given-up, attempts-exhausted, no-response, and both refusal paths (no endpoint, plain http) asserting fetch is never called and the queue is never touched.
  • Marketplace listing added with a generated placeholder screenshot, pnpm marketplace:gen re-run; release:check passes with 7 first-party listings. Both docs pages updated.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G22SRRgkUV7doefmw9NMNf


Generated by Claude Code

claude added 2 commits August 26, 2026 20:18
A handler was called with the value and the hook's context and nothing
else, so a plugin could filter a view or observe an event in memory but
could not record, enqueue or notify. Reacting to board activity — the
most obvious thing a plugin author expects to do — was not possible:
plugins/dues works around it by declaring no hooks at all.

Handlers now take a third argument, a function resolving this plugin's
runtime context: the same settings, logger, data, grants, users and
notify a task or route is handed, with the same limits. It is a function
because hooks are the hot path and a handler that never calls it costs
nothing, and because acquiring it can fail on a fixture-mode board, where
it rejects rather than pretending. Within one call it resolves once.

Additive: a two-argument handler still compiles and runs, so this is a
minor rather than a break. Callers driving a handler directly in a test
pass unavailableHookRuntime(reason).

Extracting the app's runtime builder into plugin-runtime.ts keeps the
host off the page-rendering graph, and deferring the notification
service's import breaks the notifications -> plugin-view -> plugin-host
cycle that reaching for it would otherwise create.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G22SRRgkUV7doefmw9NMNf
The first of the seeded marketplace cohort: new threads and replies
delivered to an endpoint of your choosing, with a Discord-compatible
preset.

A thread.created or post.created hook writes one row into the plugin's
own queue and returns; a task drains that queue every minute. Nothing is
sent from the request, so an unreachable endpoint never delays a
member's post. Deliveries retry on a timeout, a refused connection, a
5xx, a 408 or a 429, backing off from 30 seconds to an hour over six
attempts, and give up immediately on any other 4xx, which retrying
cannot fix.

Plain-JSON deliveries are signed HMAC-SHA256 over timestamp and body, so
a receiver can verify the delivery came from this board and cannot be
handed a captured one twice. A plain-http endpoint is refused rather
than sending the board's content over it.

The payloads carry no English of ours — a Discord embed uses the
thread's own subject and link — so the plugin adds nothing to the
translation surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G22SRRgkUV7doefmw9NMNf
@jouwdan
jouwdan changed the base branch from claude/mei-115-xt6308-hook-runtime to main August 26, 2026 21:00
@jouwdan
jouwdan merged commit a0ce129 into main Aug 26, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants