Skip to content

feat(notifications): Telegram, Discord, ntfy and webhook channels, with publicUrl, screenshots and self-destruct - #27

Merged
arg1998 merged 26 commits into
mainfrom
feat/notifications-n1-channels
Sep 29, 2026
Merged

arg1998 merged 26 commits into
mainfrom
feat/notifications-n1-channels

Conversation

@arg1998

@arg1998 arg1998 commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Notifications on your phone (N1)

N1 of the notification channels program (plan scratchpad/notification-channels-design.md, owner-approved). It builds on N0 (#25): the message contract and the delivery outbox.

What it does

  • Four channels: a Telegram bot, a Discord webhook, an ntfy topic and a generic webhook. Each is a pure renderer (contract → platform request) plus a transport, behind N0's outbox (retries, Retry-After, breaker, coalescing).

  • Edits in place, silently. Resolving an attention request edits the message and removes its buttons. A growing tool-error group edits its count.

  • Self-destruct per channel × category (default never; Telegram capped at 47 h because bots may delete for 48 h only). Delete when resolved is available, off by default. Deletes that fall due while BrowserHive is off happen at the next start.

  • Screenshots, opt-in per channel × category: an attention request (CAPTCHAs included), a vault confirmation (taken before the fill; never during one or in a secret window), a crash's last frame. Masked or unmasked, per channel. Never with recordToolResults=none or below content level full.

  • publicUrl (--publicUrl / BROWSERHIVE_PUBLIC_URL / JSON). All links use it. Its host joins the Host allow-list, and its origin passes the CSRF guard, which fixes proxies that rewrite Host. doctor, the System page and GET /system/public-url check <publicUrl>/health against an instance_id minted at each start: ok / elsewhere / behind a login / unreachable. Without it, links are labelled "Open on this computer".

  • Startup channels: a flag-only --notificationChannel "telegram:name=phone,token=env:BH_TG_TOKEN,chat=…", repeatable. Secrets go only as env:NAME; an inline secret exits 64 without echoing it, and so does a name clash with a dashboard channel. These channels show read-only in the dashboard.

  • API: /api/v1/channels CRUD, pause/resume, test send (rate-limited), a pure preview (the same renderer as a send, secrets shown as variable names), the delivery log with filters and cursor, an env-var set/missing check (never the value), and a Telegram one-tap connect (a t.me/<bot>?start=<code> link, then a 2-minute setup-only long poll). Scopes are channels:read|write, and a channels WS topic carries changes live.

  • CLI: browserhive channels list | test <name> | preview <name> [--sample] against a running server. doctor checks every channel's variables and publicUrl. The banner gains a Notify line.

  • Dashboard: Notifications → Channels, with cards (status, last delivery, 24 h counts, pause/resume/edit/duplicate/delete/test) and a 5-step wizard:

    1. Platform, with Discord's "What's the difference?" panel drawing webhook vs bot live.
    2. Credentials, with launch-specific lines and live set/missing; the draft survives a restart.
    3. Connect: Telegram one-tap and QR, or ntfy server/topic and QR.
    4. What to send: presets plus advanced options.
    5. Preview: near-exact Telegram / Discord / ntfy mocks drawn from the real renderer output, then Save and Send test.

    The Delivery log is live, with filters and "why wasn't this sent?", and the System page gains a Public address card.

Setup in short

  1. Telegram: open @Botfather, send /newbot, run export BH_TELEGRAM_TOKEN=…, and restart. Then use the wizard's one-tap link.
  2. Discord: in Channel settings, go to Integrations → Webhooks → New → Copy URL. Run export BH_DISCORD_WEBHOOK=… and restart.
  3. ntfy: install the app and scan the wizard's QR code. On ntfy.sh the topic is the password; for screenshots, prefer a self-hosted server.
  4. Links on the phone: use --publicUrl https://…, for example with Tailscale serve or your reverse proxy. The test message's Open dashboard is the proof.

The full walkthroughs are in docs/guide/notifications.md.

Spikes

Spike Result
Telegram Rich Messages (Bot API 10.1–10.3) sendRichMessage with html (h3, table, footer) plus an inline keyboard was accepted, and so was editMessageText(rich_message). Rendering on phones was not compared. D-40: classic HTML for now; two spike messages are left in the test group for you to compare.
Telegram button URLs Refused: localhost, dotless hosts, IPv6 literals. Accepted: domains and IPv4. A publicUrl of http://localhost:… failed every send, so it is fixed: links go into the text.
Discord Components V2 vs embeds The docs say V2 webhook messages may not carry files[n]/embeds, so a webhook V2 message cannot upload a screenshot. We use embeds plus a link-button action row (with_components=true). Not verified live; there is no webhook yet.
ntfy Checked against the real server binwiederhier/ntfy:v2.28.0: sequence-id replace, DELETE /<topic>/<seq>, PUT upload (only the path form or the header carries the sequence id; the query form is ignored), and a maximum of 3 actions. A server without an attachment cache refuses uploads, so we fall back to text.

Verification

  • Gates (local): bun run check passes (lint, typecheck, depcruise, unit ≈3 000 server tests plus 384 dashboard tests, and the openapi/docs/db-types checks). test:goldens, build, package:check and the website build pass. test:integration gives 67 passed / 2 skipped (the real-ntfy test needs BHDEV_NTFY_URL). E2e, run the CI way against the built daemon, gives 11 passed / 1 skipped: the 9 existing tests plus the new channels journey on desktop and phone.
  • Built daemon, against fakes and local ntfy containers:
    • A startup webhook channel via the flag delivers HMAC-signed POSTs.
    • A tool-error group sends, then edits its count, and the intermediate edit is covered.
    • Attention requests take masked and unmasked screenshots; I checked visually that the inputs are blacked out.
    • An ntfy server without an attachment cache refuses uploads; we fall back to the text alone.
    • TTL deletes work on ntfy (the message_delete event arrives). Resolving after a TTL delete gives superseded: message_deleted.
    • The publicUrl check reports ok.
    • An inline-secret flag exits 64 with the exact message and never echoes the value. BROWSERHIVE_NOTIFICATION_CHANNEL exits 64 with a hint. A name clash between a startup and a dashboard channel exits 64.
  • Real platforms (your local test bot and group, plus an ntfy.sh topic; Discord only against fakes; I recorded outcomes only):
    • Telegram and ntfy.sh test sends: OK.
    • Attention request: Telegram sendPhoto with the masked screenshot, and an ntfy.sh upload with the attachment and view actions.
    • Resolve: both edited in place.
    • After a 4-minute TTL: Telegram deleteMessage and ntfy.sh DELETE both succeeded.
    • scripts/notify-live.ts: Telegram ✅, ntfy.sh ✅ (after adding cache polling), Discord skipped.
    • This caught two bugs, now fixed: Telegram refuses buttons with a localhost publicUrl, and ntfy.sh read-back lags a publish.
  • Dashboard visual review: 74 screenshots at 1440 and 768, light and dark, all reviewed: the channels list, every wizard step for every platform, the preview mocks, "What's the difference?", the delivery log and its detail sheet, and the System Public address card in each state. Not attached here because they come from a local run.

Contract change

  • OpenAPI:
    • new listChannels, createChannel, getChannel, updateChannel, deleteChannel, pauseChannel, resumeChannel, testChannel, previewChannel, listDeliveries, getDelivery, checkChannelEnv, startTelegramConnect, getTelegramConnect and getPublicUrlStatus;
    • HealthResponse.instance_id (optional);
    • error codes CHANNEL_NOT_FOUND, CHANNEL_NAME_TAKEN, CHANNEL_READ_ONLY, CHANNEL_NOT_READY, CHANNEL_KIND_UNAVAILABLE, CHANNEL_PLATFORM_ERROR and DELIVERY_NOT_FOUND.
  • Config: a new key publicUrl, plus the flag-only --notificationChannel. BROWSERHIVE_NOTIFICATION_CHANNEL and a notificationChannel file key fail with a hint.
  • WS: topic channels (channel.changed, channel.removed, delivery.updated); the scopes channels:read and channels:write.
  • Contract (NotificationChannelRules): gains mask_images (additive; schema stays 1).
  • Database: no schema change (N0's v5 tables).
  • Goldens: the WS protocol, help, exports snapshot and OpenAPI goldens are re-blessed after reading the diffs, and there are new renderer goldens under packages/core/test/goldens/notifications/.

Please check on your phone

  1. The two spike messages in the Telegram test group, classic HTML vs Rich Message: which reads better?
  2. Telegram: add a channel through the wizard's one-tap link, then Send test. Tap Open dashboard; it opens only if publicUrl is reachable from the phone.
  3. Trigger an attention request with screenshots on. Check the photo, the masked fields, and the edit to "Resolved" after you resolve it. After the TTL, check that the message disappears.
  4. ntfy: scan the wizard's QR code and check the notification, the tags, the priority and the view actions.
  5. Discord (when you have a webhook): an embed with the screenshot, the edit that keeps the image, and the delete.
  6. Optionally add the live-notify label to this PR to run notify-live.yml (it needs your approval of the notify-live environment).

Also in this PR

  • The sidebar now has a Notifications entry. It had none before; the page was reachable only through the bell.
  • A new dashboard dependency: uqr (MIT) for QR codes.

Known and not chased

Follow-ups for N2 (handoff scratchpad/notifications-handoff-N1.md)

  • Command tokens plus notification_actions; mint tokens and pass them to RenderContext.actToken.
  • The Telegram callback long-poll loop, one per bot token and not concurrent with the connect flow.
  • Discord bot mode: the renderer already draws it; it needs the gateway, bot sends and the picker.
  • ntfy topic B, after the http-action spike.
  • Allow-lists: the Telegram connect already captures the connecting user's id.

…rl and screenshots

Spec 08 gains publicUrl (§5.8), the per-kind --notificationChannel
parameters and the channels CLI; spec 03 the channels API, the channels
WS topic, the publicUrl check route and the platform mapping (§9.5);
spec 04 the channels pages; specs 09 and 10 the tests, error codes and
redaction sinks. D-33 to D-39 record what N1 implements, and D-40 the
platform message formats (Telegram HTML, Discord embeds).
The channels REST DTOs (views that never carry a secret value, input
with environment variable names, preview, delivery log, env check,
Telegram connect), the channels WS topic and events, channels:read and
channels:write scopes, the publicUrl key and grammar, GET /health
instance_id, the public-url status DTO, per-platform requirements with
one shared config check, preview samples and delivery reason texts, and
the channel error codes.
…ports

The pure renderer half of a platform adapter (shared by sends and the
preview), the notification image store and reader, the setup-only
Telegram connect calls and the one-shot URL probe of the publicUrl
check.
…l image rule

Parses --notificationChannel into startup channels with secrets as
variable names only (an inline secret is refused without echoing it),
answers the env and config-file spellings with a hint naming the flag,
captures attention, vault-confirm (before the fill) and crash
screenshots when a channel wants them, keeps per channel only the image
variant it may see, adds the public link builder, the delivery log
filters and per-channel stats.
Realistic messages for every preview sample (attention, its resolution,
vault confirm, tool errors, crash, degraded, test), built through the
real producers with fixed ids and times, optionally with a masked or
unmasked screenshot block.
…ters

Pure renderers shared with the preview and one transport per platform:
Telegram HTML messages and photos with inline keyboards, edits of text
or caption and deletes; Discord webhook embeds with link buttons and a
kept screenshot on edit (bot-mode buttons rendered for the preview); ntfy
JSON publishes or uploads replaced by sequence id and deleted; the
generic webhook posting the signed contract with same-origin redirects
only. One HTTP helper classifies every failure and never echoes a secret.
Also the Telegram connect calls, the screenshot store and the publicUrl
probe.
…nnels CLI

The channels API (views without secret values, CRUD with read-only
startup channels, pause and resume, the test send, the pure preview,
the delivery log, the environment check and the Telegram connect
flow), the channels WS events, GET /system/public-url with the health
instance id, publicUrl host and origin trust, the --notificationChannel
flag on serve and doctor, browserhive channels list|test|preview, and
the doctor publicUrl and channel checks.
Bun.serve fakes of the Telegram Bot API, Discord webhooks, ntfy and a
webhook receiver with scripted failures; renderer golden files per
platform, sample and variant (screenshot, local links, counts, edits,
Discord bot mode); escaping and length properties; every adapter against
the fakes including 429, 5xx, timeouts and vanished messages; the full
path event to send, edit and TTL delete on SQLite per platform; and the
redaction sentinel rendered through every platform and webhook body.
A non-required ntfy job runs the adapter against binwiederhier/ntfy
v2.28.0 (publish, read back, upload, replace by sequence id, delete).
notify-live.yml runs weekly, on dispatch and on PRs labelled live-notify
from this repository, in the notify-live environment: real Telegram, a
Discord webhook and ntfy, send with a screenshot, read back, edit and
delete, skipping a platform without secrets and opening an issue on
failure.
…icUrl into the server

Composition registers the Telegram, Discord, ntfy and webhook
factories and renderers, the screenshot store and snapshots, the
channel service, the publicUrl link builder, host trust on /mcp and the
check; startup channels come from --notificationChannel. The help lists
the flag and the channels command; route, doctor and help tests cover
them; OpenAPI and the references are regenerated.
… anywhere in a preview

A secret parameter can appear in a request body as well as the path (an
ntfy topic kept in a variable); the preview shows the variable name in
both.
…ws and delivery log

Notifications gains Inbox, Channels and Delivery log sections and a
sidebar entry. Channel cards show status, secret variables (set or
missing, never values), 24 h counts and test sends; startup channels
are read-only. The add-channel wizard keeps its draft in localStorage
and walks platform, credentials with launch-method snippets, connect
(Telegram one-tap link with QR, ntfy subscribe QR, webhook URL), rules
(presets, quiet hours, content level, screenshots with masking,
self-destruct capped at 47 h on Telegram) and a preview drawn from the
renderer's own requests. The Discord difference panel draws webhook
and bot messages side by side. The System page shows the public
address check.
…ust and screenshots

Also sends an ntfy notification without its screenshot when a
self-hosted server refuses uploads, names a startup/dashboard clash in
the boot error, shows channels and the link target in the banner, and
aligns the specs (viewport-size screenshots, two Telegram buttons per
row, D-40 wording).
…enshots and self-destruct

The notifications guide gains per-platform setup (BotFather, Discord
webhooks, the ntfy app and QR code, the webhook contract and its
signature), the public address with Tailscale, reverse proxies and the
check, screenshots, self-destruct, startup channels, the delivery log
and what leaves the machine; the CLI, security and dashboard guides and
a minor changeset follow.
…ld be refused

The Bot API refuses URL buttons whose host has no dot (localhost, a bare
machine name) or is an IPv6 literal, which failed every send with a
localhost publicUrl. Found against the real Bot API; domains and IPv4
addresses keep their buttons.
A live run against ntfy.sh read the topic back before the publish
showed up; the check now polls for up to ten seconds per step.
…view fixes

Component tests over responses captured from a real daemon (cards,
wizard, Telegram connect, previews, public address), the channel API
coverage check, Discord and Telegram timestamps in the mocks, one
publicUrl note, the delivery log stacked below 1024 px, clearer save
and connect errors.
…the test starts

Adds a webhook channel through the wizard, previews it, saves it,
sends a real test that the receiver records, finds it in the delivery
log and deletes it, on desktop and phone. Links the rules step to the
guide's what-leaves-your-machine section.
@arg1998
arg1998 marked this pull request as ready for review September 29, 2026 01:25
The preview listed the missing-publicUrl hint twice in different words.
The server note is now the single source (the CLI prints it too), worded
as what happens and what to do, with publicUrl shown as code in the
dashboard. The act-button note now says what the buttons do today.
@arg1998
arg1998 merged commit fb94fbc into main Sep 29, 2026
23 of 25 checks passed
@arg1998
arg1998 deleted the feat/notifications-n1-channels branch September 29, 2026 02:28
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.

1 participant