Skip to content

feat(notifications): answer from your phone — act buttons on Telegram, Discord and ntfy, and Telegram Rich Messages - #28

Merged
arg1998 merged 15 commits into
mainfrom
feat/notifications-n2-act-buttons
Sep 29, 2026
Merged

arg1998 merged 15 commits into
mainfrom
feat/notifications-n2-act-buttons

Conversation

@arg1998

@arg1998 arg1998 commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Answer from your phone (N2)

N2 of the notification channels program (plan scratchpad/notification-channels-design.md, owner-approved), on top of N1 (#27).

What it does

  • Act buttons, per channel, off by default ("Answer from the chat"). A notification that waits for you carries Mark resolved / Reject (attention) or Approve / Deny (vault fill). A press does exactly what the dashboard button does; the message is then edited silently: buttons gone, "Resolved on Telegram by … after 42 s".
  • Telegram: callback buttons (coloured: success/danger/primary) over a getUpdates long-poll hub, one poller per bot token (shared with the setup's /start wait, so no 409), offsets stored so a restart neither loses nor repeats a press; presses made while BrowserHive was off (Telegram keeps them 24 h) are handled at start and refused if stale.
  • Telegram Rich Messages (owner decision after N1): sendRichMessage / editMessageText(rich_message) with heading, summary, screenshot as a media block, facts as a compact table, real tables, expandable quotes, code, footer; the screenshot is re-used by file_id on edits; skip_entity_detection so page text never becomes a mention/command. Classic HTML is only the fallback (400 per message; a 404 makes the channel classic for the run); old messages keep being edited in their format.
  • Discord bot mode (second mode, one per channel, switching keeps rules): bot REST send/edit/delete with the same embed, interactive buttons, a small gateway client of our own (Hello/Identify intents 0/heartbeat + zombie detection/Resume/Reconnect/Invalid Session/fatal close codes), the 3-second rule (ephemeral answer, or deferred + edited follow-up for a slow command), connection state on the card. Setup: invite link with the minimal permissions (View Channels, Send Messages, Embed Links, Attach Files = 52224), server and channel picker via the bot API, and a This is me button that links the operator's account ("Connected as ").
  • ntfy two-way (reply topic, owner-approved): act buttons are http actions that make the phone POST bh1:<token> to topic B; BrowserHive subscribes to topic B (streaming, since= catch-up) and replaces the topic-A notification.
  • Webhook: with act buttons on, the contract's act actions go out unchanged (no tokens, no callback endpoint); the receiver answers through POST /api/v1/attention/{id}/resolve with its own API token (documented).
  • Audit: every press of a real button → notification_actions (audit class) and the new Notifications → Actions page (live), with Allow this person for a refused presser.
  • API/CLI: GET /channels/actions, POST /channels/discord/bot|channels|connect, GET /channels/discord/connect/{id}, ChannelView.connection, WS action.recorded; browserhive channels list shows the Discord mode and an ANSWERS column; startup flag params mode=bot, token, channel, guild, reply, replyToken, actButtons, allow.

Security model

  • Opt-in per channel. Each button carries a one-time token (bh1: + 11 URL-safe chars = 66 random bits), stored only as SHA-256, bound by its row to channel, notification, action and command, valid 24 h, single use (atomic claim), only in the chat it was sent to, only while the request is still open.
  • Telegram/Discord: only the channel's allowed people may press (default: the person who connected the chat — /start or This is me). A refused presser is told privately (Telegram callback answer / ephemeral Discord reply) where the admin adds them, with their own id; the admin can Allow this person from the audit.
  • ntfy has no user identity: whoever can read topic A can press — keep it private; topic B can be write-only for everyone; someone who learns only topic B cannot act (tokens are unguessable, single-use).
  • The agent sees resolved_by: "telegram" (platform only), never the operator's chat identity. Tokens never reach logs, the delivery log, previews or the API. Unknown tokens are counted, not audited (no audit flooding).
  • Only outbound connections (long polling, the gateway WebSocket, an ntfy subscription); no public endpoint. Bot tokens are env var names only (D-33).

Setup summary

Telegram: nothing new — switch Answer from the chat on. Discord: Developer Portal → New Application → Bot → Reset Token → env var → (make it private: Installation → Install Link = None, then Public Bot off) → wizard: Invite the bot → pick server/channel → This is me. ntfy: add a reply topic in Connect. Full click-paths and troubleshooting in docs/guide/notifications.md (#answer-from-your-phone, #discord).

Spike results

  • ntfy topic B: works. Local binwiederhier/ntfy:v2.28.0 and ntfy.sh: an http action targeting the server's own topic B is accepted; the streaming subscription receives a phone-style POST within ~1 s; since=<id> catches up; replace by sequence_id works. docs.ntfy.sh lists http actions for Android and iOS (iOS since app 1.1). Recorded as D-42.
  • Telegram Rich Messages on the real API: multipart attach://shot media, styled inline keyboard, rich edit re-using the file_id, delete — all accepted.
  • Discord webhook read-back: a file an embed shows moves into the embed (the message's attachments stays empty) — N1's live check expected attachments.length === 1 and failed on the real webhook (same on main); fixed.

Verification evidence

Fakes (CI): act-buttons.sqlite.test.ts — the whole path per platform (event → outbox → send with minted tokens → press over the listener → command as the chat actor → revision → silent edit without buttons; second press refused; offsets stored); press-listeners.test.ts — Telegram poller (offsets resumed after restart, re-delivered update handled once, 409 offline, alert for allow-list refusals, /start wait sharing the poller), a fake Discord gateway (Identify intents 0, quick ephemeral answer, deferred + follow-up, ephemeral refusal, Resume after a drop, Invalid Session → Identify, zombie detection, refused token offline, This is me claim), ntfy reply stream with since= resume; actions.test.ts (token lifecycle: expiry, reuse/double press, wrong channel/chat, disabled, stale, allow-list, unknown not audited, executor failures, redaction of tokens); renderer goldens (telegram/, telegram-classic/, *-act, Discord bot variants) and property tests; real ntfy container round trip (ntfy-live.test.ts).

Real platforms (owner's local test bot/server/topic; values never printed):

  • scripts/notify-live.ts: Telegram Rich Message with screenshot + act buttons (send, edit removing buttons, delete) ✅; Discord webhook (screenshot kept by edit, delete) ✅; Discord bot (gateway Ready with intents 0, send with screenshot + interactive buttons, read back, edit, delete) ✅; ntfy.sh (send/replace/delete + reply-topic round trip) ✅.
  • A running daemon (from source) with startup channels Telegram + ntfy + Discord bot + a dashboard Discord webhook channel:
    • ntfy: tapped "Mark resolved" exactly as the phone does → the agent's request_attention returned {"status":"resolved","resolved_by":"ntfy"}; all messages edited.
    • Discord bot, pressed by the owner on a real device: first press refused (empty allow-list; ephemeral answer with the presser's id), then with the id allowed the owner's press resolved the request: {"status":"resolved","resolved_by":"discord"}; Telegram, ntfy and both Discord messages edited (revision 2). INTERACTION_CREATE over the real gateway ✅.
    • Discord webhook: screenshot, edit (✅ title, image kept), TTL delete (404) — no regression.
  • Dashboard: component + axe tests, e2e (desktop + phone) 11 passed / 1 skipped, screenshots at 1440 and 768, light and dark (Actions view with Allow this person, act-button settings with allowed people, Discord mode choice + What's the difference, Discord bot connect incl. Connected as, ntfy reply topic, cards with connection states, Telegram rich / Discord bot / ntfy previews with act buttons).

Local gate: bun run check ✅ (3122 + 411 tests), test:goldens ✅, build ✅, package:check ✅, website build ✅, gitleaks over the branch ✅. test:integration: 66 pass / 3 skip / 1 fail = sandbox-stealth "required sandbox" on this host's installed Chrome, failing identically on main (host AppArmor), not related.

Contract change

  • Schema v6 (0006-notification-actions, compatible: true, min reader unchanged): notification_action_tokens, notification_actions, notification_cursors; v6 fixture + golden, fresh == migrated.
  • OpenAPI: 5 new operations (getDiscordBot, listDiscordChannels, startDiscordConnect, getDiscordConnect, listChannelActions); ChannelView.connection; ChannelPreviewRequest.secret_refs (names only); regenerated.
  • Contract (NotificationMessage): unchanged (schema 1). WS: action.recorded on channels. Enums: NotificationActionOutcome, NotificationListenerState.
  • Decisions: D-38 rewritten (bot mode implemented), D-40 rewritten (Rich Messages + fallback), new D-41 (act buttons), new D-42 (ntfy reply topic); specs 02/03/04/08/09/10 updated (spec commits first).

Phone checklist (owner)

Start BrowserHive with act buttons on (dashboard: channel → What to send → Answer from the chat), trigger an attention request (e.g. an agent's request_attention), then on the phone:

  1. Telegram: the message is a Rich Message (heading, table, screenshot); press Mark resolved → a short notice "Marked resolved…", the message loses its buttons and says "Resolved on Telegram by ". (First press may be refused with your id if the channel was created by flag without allow=: add it under Allowed people or via Actions → Allow this person, press again.)
  2. Telegram, stale: press the button of a request that was already answered (or wait for it to time out) → "This request is no longer waiting." and nothing happens.
  3. Discord bot: press Reject on a new request → an ephemeral "Rejected…" only you see; the message loses its buttons. Press a button with BrowserHive stopped → Discord says "This interaction failed" (expected: Discord keeps no presses).
  4. ntfy (Android and iOS): tap Mark resolved in the notification → the notification is replaced by the resolved one. Repeat on iOS if you have it.
  5. Refusal privacy: ask someone else in the Telegram group / Discord channel to press → only they see the refusal; it appears under Notifications → Actions with Allow this person.
  6. Discord setup: run the wizard's bot mode once end to end (invite, server/channel picker, This is me → "Connected as ").

Known / follow-ups for N3

  • CI: macOS and Windows integration are known-failing (macOS: integration tests are flaky (timeouts, then Chromium killed) #23, Windows: integration failures #2).
  • Digest/anomaly scheduler, per-channel time zone, late windows, table/chart blocks: N3 (handoff scratchpad/notifications-handoff-N2.md; notification_cursors can hold per-channel digest watermarks; routing per time zone is the main design question).
  • Open: a refused ntfy press shows nothing on the phone; vault Approve acts without an in-chat confirm; ntfy cursor rows are not removed with their channel.

…reply topic and Telegram Rich Messages

D-38 bot mode, D-40 Rich Messages with a classic fallback, new D-41
(single-use command tokens, allow-lists, presses over outbound
connections) and D-42 (ntfy answers through a second topic, after the
spike). Spec 03 gains schema v6, the press flow (§9.6) and the Discord
setup and action audit endpoints; 04, 08, 09 and 10 follow.
…and the per-mode channel checks

ActionRow and the actions page, the Discord bot, channel picker and
account-link DTOs, ChannelView.connection, the action.recorded feed
event, the NotificationActionOutcome and NotificationListenerState
enums, Discord bot mode and the ntfy reply topic in the platform
table, and checkChannelRules for act buttons and allow-lists.
…and listener cursors

notification_action_tokens (hashes only, single-use claim, cascades with
the channel and notification), notification_actions (audit class, keeps
the channel name), notification_cursors (Telegram offsets, ntfy ids).
Compatible migration, v6 fixture and golden, SQLite and in-memory
repositories under one conformance suite, and retention for both.
…fy, and Telegram Rich Messages

Command tokens minted by the outbox before the platform call; presses
checked (channel and chat, act buttons on, single use, 24 h, request
still open, allow-list), run through the attention and vault services
as telegram:<id>, discord:<id> or ntfy:topic-b, audited, published and
answered. One getUpdates poller per Telegram bot (shared with the
/start wait, offsets stored), a small Discord gateway client (identify,
heartbeat, resume, the 3-second answer), and the ntfy reply-topic
subscription with since= catch-up. Telegram sends Rich Messages with a
classic HTML fallback; Discord bot mode sends through the bot API.
Channel API: Discord bot setup, account link, the action audit and the
listener state on every channel.
…a Discord bot and the ntfy reply topic

The Discord webhook read-back now checks the embed image on Discord's
CDN: a file an embed shows moves into the embed, so the message's
attachments list stays empty (it failed on the real webhook).
…m, Discord bot setup, the ntfy reply topic and their security
…ors, safer flag errors, ntfy.sh note per server

The first getUpdates does not wait, so a card shows connected at once.
A Discord 400 Invalid Form Body names the refused field. An unknown
--notificationChannel parameter that is not name-like is not echoed (a
pasted token). The ntfy.sh attachment note shows only for ntfy.sh. The
live check gives each dummy button its own custom_id and reads the bot
message back.
…ot channel says where it posts

A draft ntfy channel with its reply topic in a variable now previews its
answer buttons (only valid variable names are used; nothing else is
echoed). A Discord bot channel's target hint names its channel and
server instead of a webhook variable.
…people, Discord bot setup, connection state and the Actions audit

The What to send step switches Answer from the chat on (disabled with
the reason where presses cannot arrive) and edits the allowed people.
Discord bot mode: the Developer Portal click-path and its pitfalls,
invite link, server and channel picker, and the This is me account link
(Connected as <name>). ntfy: an optional reply topic with its security
note. Cards show whether answers reach BrowserHive. Previews draw
Telegram Rich Messages with coloured buttons, the Discord bot's buttons
and ntfy's answering actions. Notifications → Actions lists every press,
live, with Allow this person for a refused presser.
@arg1998
arg1998 marked this pull request as ready for review September 29, 2026 04:14
@arg1998
arg1998 merged commit 9470feb into main Sep 29, 2026
23 of 25 checks passed
@arg1998
arg1998 deleted the feat/notifications-n2-act-buttons branch September 29, 2026 04:46
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