feat(notifications): answer from your phone — act buttons on Telegram, Discord and ntfy, and Telegram Rich Messages - #28
Merged
Conversation
…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.
…d ops run from a chat
…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.
…ers reach BrowserHive
…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.
…and troubleshooting
… audit, private refusals
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
getUpdateslong-poll hub, one poller per bot token (shared with the setup's/startwait, 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.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 byfile_idon edits;skip_entity_detectionso 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.httpactions that make the phone POSTbh1:<token>to topic B; BrowserHive subscribes to topic B (streaming,since=catch-up) and replaces the topic-A notification.actactions go out unchanged (no tokens, no callback endpoint); the receiver answers throughPOST /api/v1/attention/{id}/resolvewith its own API token (documented).notification_actions(audit class) and the new Notifications → Actions page (live), with Allow this person for a refused presser.GET /channels/actions,POST /channels/discord/bot|channels|connect,GET /channels/discord/connect/{id},ChannelView.connection, WSaction.recorded;browserhive channels listshows the Discord mode and an ANSWERS column; startup flag paramsmode=bot,token,channel,guild,reply,replyToken,actButtons,allow.Security model
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 stillopen./startor 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.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).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
binwiederhier/ntfy:v2.28.0and ntfy.sh: anhttpaction 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 bysequence_idworks. docs.ntfy.sh listshttpactions for Android and iOS (iOS since app 1.1). Recorded as D-42.attach://shotmedia, styled inline keyboard, rich edit re-using thefile_id, delete — all accepted.attachmentsstays empty) — N1's live check expectedattachments.length === 1and 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,/startwait 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 withsince=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) ✅.request_attentionreturned{"status":"resolved","resolved_by":"ntfy"}; all messages edited.{"status":"resolved","resolved_by":"discord"}; Telegram, ntfy and both Discord messages edited (revision 2). INTERACTION_CREATE over the real gateway ✅.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 onmain(host AppArmor), not related.Contract change
0006-notification-actions,compatible: true, min reader unchanged):notification_action_tokens,notification_actions,notification_cursors; v6 fixture + golden, fresh == migrated.getDiscordBot,listDiscordChannels,startDiscordConnect,getDiscordConnect,listChannelActions);ChannelView.connection;ChannelPreviewRequest.secret_refs(names only); regenerated.NotificationMessage): unchanged (schema 1). WS:action.recordedonchannels. Enums:NotificationActionOutcome,NotificationListenerState.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:allow=: add it under Allowed people or via Actions → Allow this person, press again.)Known / follow-ups for N3
scratchpad/notifications-handoff-N2.md;notification_cursorscan hold per-channel digest watermarks; routing per time zone is the main design question).