diff --git a/.changeset/notification-act-buttons.md b/.changeset/notification-act-buttons.md new file mode 100644 index 0000000..b8e0db4 --- /dev/null +++ b/.changeset/notification-act-buttons.md @@ -0,0 +1,14 @@ +--- +"browserhive": minor +--- + +Answer from your phone: Approve, Reject and Mark resolved right in Telegram, Discord and ntfy, and richer Telegram messages. + +- **Answer without opening the dashboard.** Switch on **Answer from the chat** for a channel, and a notification that waits for you carries buttons that act: **Mark resolved** and **Reject** for an attention request, **Approve** and **Deny** for a vault fill. Press one and BrowserHive does what the same button in the dashboard does, then edits the message: the buttons disappear and it says who answered ("Resolved on Telegram by … after 42 s"). Off by default. +- **Only the right person, only once.** On Telegram and Discord only the accounts on the channel's allow-list may press; by default that is the person who connected the chat, and anyone else is told their id so you can add them. Every button works once, for 24 hours, only in its own chat, and only while the request still waits. Every press is listed under the new **Notifications → Actions**. The agent learns that you answered from Telegram, Discord or ntfy, never your chat identity. +- **Discord bot mode.** A Discord channel can now use a bot instead of a webhook, the only way to press buttons in Discord. The wizard walks through the Developer Portal, builds the invite link with the minimal permissions, lists your servers and channels, and links your account with a **This is me** button. The channel card shows whether the bot is connected. +- **ntfy answers through a second topic.** Give an ntfy channel a reply topic, and its buttons make your phone post the answer there; BrowserHive listens and updates the notification. Works on Android and iOS. +- **Nothing to expose.** Every connection goes out from your machine (Telegram long polling, the Discord gateway, an ntfy subscription). Presses made while BrowserHive was stopped are handled at the next start when the platform kept them. The webhook channel carries the act actions as they are, and your receiver answers through the REST API. +- **Telegram Rich Messages.** Telegram notifications now have a heading, the facts as a table, real tables, collapsible quotes and coloured buttons, with the screenshot inside the message. If Telegram refuses one, the classic format is sent instead. +- **Setup and terminal.** Startup channels take `actButtons=true` and `allow=`, `mode=bot` for Discord and `reply=` for ntfy. `browserhive channels list` shows whether answers reach BrowserHive. +- New REST endpoints: `GET /api/v1/channels/actions` and the Discord bot setup under `/api/v1/channels/discord/…`; channels report `connection`; the `channels` WebSocket topic adds `action.recorded`. The database moves to schema v6 (three new tables; an older release still opens it). diff --git a/.github/workflows/notify-live.yml b/.github/workflows/notify-live.yml index 25d986c..479210c 100644 --- a/.github/workflows/notify-live.yml +++ b/.github/workflows/notify-live.yml @@ -1,7 +1,9 @@ name: notify-live -# Live notification check (spec 09 §8): real Telegram, a Discord webhook and ntfy, through the -# real adapters: send with a screenshot, read back where the platform allows, edit, delete. It +# Live notification check (spec 09 §8): real Telegram (a Rich Message with act buttons), a Discord +# webhook, a Discord bot (gateway and interactive buttons) and ntfy (with a reply-topic round +# trip), through the real adapters: send with a screenshot, read back where the platform allows, +# edit, delete. It # guards against the fakes drifting from the platforms. Runs weekly, on dispatch, and on pull # requests labelled `live-notify` from this repository (never from forks). The `notify-live` # environment holds the secrets and needs the owner's approval. A platform whose secrets are not @@ -47,6 +49,8 @@ jobs: TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }} TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_CHANNEL_ID: ${{ secrets.DISCORD_CHANNEL_ID }} NTFY_TOPIC: ${{ secrets.NTFY_TOPIC }} run: bun scripts/notify-live.ts - name: Open or update the drift issue diff --git a/docs/guide/attention.md b/docs/guide/attention.md index 0303dad..e7e960c 100644 --- a/docs/guide/attention.md +++ b/docs/guide/attention.md @@ -49,7 +49,7 @@ Outcomes: ## The operator's side -A new request shows up as a notification, on the **Attention** page, and as a banner on the session's page. +A new request shows up as a notification, on the **Attention** page, and as a banner on the session's page. With a [notification channel](notifications.md#channels) it reaches your phone too, and with act buttons on you can answer it there ([answer from your phone](notifications.md#answer-from-your-phone)); the agent's result then says `resolved_by: "telegram"` (or `discord`, `ntfy`), never your chat identity. 1. Open the request. You see the reason, mode, options and how long the agent has been waiting. 2. For `takeover` requests, click **Open live & take over**. The live view accepts your mouse, wheel, keyboard and touch input and sends it to the agent's browser. Input is allowed only while this request is open and is checked on every event. diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 4eeaa31..25ee3e1 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -56,11 +56,11 @@ A list value joins its items with `+`; a value cannot contain `,` (write `%2C`). | Platform | Parameters | |---|---| | `telegram` | `name`, `token=env:NAME`, `chat` (a chat id), optional `thread` (a forum topic id) | -| `discord` | `name`, `webhook=env:NAME` (the webhook URL), optional `mode=webhook` | -| `ntfy` | `name`, `topic` (a topic, or `env:NAME`), optional `server` (default `https://ntfy.sh`), `token=env:NAME` | +| `discord` | webhook mode: `name`, `webhook=env:NAME` (the webhook URL). Bot mode: `name`, `mode=bot`, `token=env:NAME` (the bot token), `channel` (the channel id), optional `guild` (the server id) | +| `ntfy` | `name`, `topic` (a topic, or `env:NAME`), optional `server` (default `https://ntfy.sh`), `token=env:NAME`, `reply` (the [reply topic](notifications.md#ntfy-a-second-topic-for-answers) for act buttons, or `env:NAME`), `replyToken=env:NAME` | | `webhook` | `name`, `url` (a URL, or `env:NAME`), optional `secret=env:NAME` (the signing key) | -Rules, all optional: `categories` (`needs-you+problems+wrap-ups+reports+system`), `min` (`info`, `warn`, `error`, `critical`), `sessions` (session name patterns such as `shop-*`), `harness`, `content` (`counts`, `titles`, `full`), `quiet=22:00-07:00` with `tz=Europe/Berlin`, `ttl.=2h` (Telegram at most `47h`), `deleteWhenResolved` (`true`, or a `+` list of categories), `images` (a `+` list of categories; needs `content=full`) and `maskImages=true`. +Rules, all optional: `categories` (`needs-you+problems+wrap-ups+reports+system`), `min` (`info`, `warn`, `error`, `critical`), `sessions` (session name patterns such as `shop-*`), `harness`, `content` (`counts`, `titles`, `full`), `quiet=22:00-07:00` with `tz=Europe/Berlin`, `ttl.=2h` (Telegram at most `47h`), `deleteWhenResolved` (`true`, or a `+` list of categories), `images` (a `+` list of categories; needs `content=full`), `maskImages=true`, `actButtons=true` ([answer from your phone](notifications.md#answer-from-your-phone); Telegram, Discord bot mode, ntfy with `reply`, webhook) and `allow` (a `+` list of Telegram or Discord user ids allowed to press them; not for ntfy). ## `init` @@ -139,7 +139,7 @@ Token commands work on the database directly when the server is stopped, or thro | Command | Meaning | |---|---| -| `channels list [--json]` | Every notification channel: platform, status (and "from startup"), where it sends, whether its variables are set, the last delivery and the last 24 hours. | +| `channels list [--json]` | Every notification channel: platform (with the Discord mode), status (and "from startup"), where it sends, whether its variables are set, whether chat answers reach BrowserHive (`connected`, `reconnecting`, `offline (reason)`, or `—` when act buttons are off), the last delivery and the last 24 hours. | | `channels test [--json]` | Sends a real test message. Exit `0` when the platform accepted it, `1` with the reason when it did not. | | `channels preview [--sample ] [--json]` | Prints the platform request a send would make (secrets shown as variable names); sends nothing. Samples: `attention` (default), `attention-resolved`, `vault-confirm`, `tool-errors`, `crash`, `degraded`, `test`. | diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index a4cb8af..85d3171 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -87,4 +87,4 @@ Version, transport, uptime, bind address, sessions live versus the cap, open att Persisted notifications for attention requests, tool errors, crashed sessions and pending vault confirmations, grouped by day. Mark as read or dismiss, individually or all at once. Once an attention request or vault confirmation is settled (in the dashboard, by a timeout, or elsewhere), its row keeps its place and shows the outcome as a small pill (**resolved**, **expired**, or **closed** when the agent stopped waiting), and a toast still on screen for it closes. See [Notifications](notifications.md). -**Channels** (Notifications → Channels) sends notifications to your phone through Telegram, Discord, ntfy or a webhook: one card per channel with its status, last delivery and 24-hour counts, and an **Add channel** wizard with a live preview and a test message. **Delivery log** lists every send, edit and delete, and says why anything was not sent. See [Channels](notifications.md#channels). +**Channels** (Notifications → Channels) sends notifications to your phone through Telegram, Discord (webhook or bot), ntfy or a webhook: one card per channel with its status, last delivery, 24-hour counts and, when act buttons are on, whether answers from the chat reach BrowserHive; and an **Add channel** wizard with a live preview and a test message. Its **What to send** step switches **Answer from the chat** on and edits who may answer. **Delivery log** lists every send, edit and delete, and says why anything was not sent. **Actions** lists every press of an act button: who, which button, from which chat, and what happened. See [Channels](notifications.md#channels) and [Answer from your phone](notifications.md#answer-from-your-phone). diff --git a/docs/guide/notifications.md b/docs/guide/notifications.md index 29b5e8a..74024a9 100644 --- a/docs/guide/notifications.md +++ b/docs/guide/notifications.md @@ -2,9 +2,10 @@ BrowserHive tells you when something needs you or went wrong: an agent asked for help, a vault fill waits for your approval, a session crashed, tools keep failing, or BrowserHive itself is degraded. Notifications are stored in the database, so they survive reloads and restarts, and they appear in the dashboard's bell, as toasts and on the **Notifications** page. -BrowserHive can also put them on your phone: through your own Telegram bot, a Discord webhook, an ntfy topic or a webhook of your own. This page explains what produces a notification, how one changes over its life, how to set up each channel, and what leaves your machine. +BrowserHive can also put them on your phone: through your own Telegram bot, a Discord webhook or bot, an ntfy topic or a webhook of your own. And you can answer from there: **Approve**, **Reject** or **Mark resolved** right in the chat. This page explains what produces a notification, how one changes over its life, how to set up each channel, how answering from your phone works, and what leaves your machine. - [Channels: set one up in two minutes](#channels) +- [Answer from your phone](#answer-from-your-phone): act buttons on Telegram, Discord and ntfy - [Public address: links that open on your phone](#public-address) - [Screenshots](#screenshots), [self-destruct](#self-destruct), [startup channels](#startup-channels), [the delivery log](#delivery-log) - [What leaves your machine](#what-leaves-your-machine) @@ -71,7 +72,9 @@ Secrets are never stored: a channel holds the **name** of the environment variab 2. Put it in a variable where BrowserHive runs, for example `export BH_TELEGRAM_TOKEN='123456789:AA…'`, and restart BrowserHive. 3. In the wizard's **Connect** step, tap the one-time link (or scan its QR code with your phone). It opens a chat with your bot and sends `/start`. BrowserHive waits up to two minutes for it and fills in the chat by itself. For a group, use **Add to a group** instead, pick the group, and the bot posts there. For a forum topic, send the start link inside that topic. -Messages use Telegram's HTML formatting. A notification with a screenshot is a photo with a caption (Telegram allows 1 024 characters in a caption; longer messages end with "… Open in BrowserHive"). Buttons are links. When an attention request is resolved, the message is edited in place, silently, and its buttons disappear. +Messages are Telegram **Rich Messages**: a heading with the severity or outcome, the summary, the screenshot, the facts as a compact table, real tables, collapsible quotes and code blocks. Page text never turns into a mention or a command. Buttons sit below the message: links, and with [act buttons](#answer-from-your-phone) on, **Mark resolved** and **Reject** (or **Approve** and **Deny** for a vault fill), coloured green and red. When a request is settled, the message is edited in place, silently: the buttons disappear and the heading shows who answered. + +If Telegram ever refuses a Rich Message (a formatting it rejects, or a self-hosted Bot API server that does not know them yet), BrowserHive sends the same notification as a classic HTML message instead, so nothing is lost; messages already sent that way keep being edited that way. Telegram lets a bot delete its own messages for **48 hours** only, so self-destruct timers on a Telegram channel go up to 47 hours. Telegram's own auto-delete timer (chat settings → Auto-delete messages) is a good backstop. @@ -83,7 +86,27 @@ Telegram lets a bot delete its own messages for **48 hours** only, so self-destr Messages are an embed: a coloured bar by severity, the facts as fields, the screenshot as the embed image, and link buttons below. Edits are silent; deletes work at any age. -**Webhook or bot?** A Discord channel uses one mode. Webhook mode takes thirty seconds and needs no connection, but its buttons can only open links. Bot mode (a Developer Portal application with a bot token) is the only way to press **Approve** or **Reject** right in Discord, and keeps one outbound connection to Discord while BrowserHive runs. Bot mode arrives with act buttons in a later release; the wizard's **What's the difference?** panel shows both message styles side by side. +**Webhook or bot?** A Discord channel uses one mode, and you can switch later without losing its rules. Webhook mode takes thirty seconds and needs no connection, but its buttons can only open links. Bot mode takes about three minutes and is the only way to press **Approve** or **Reject** right in Discord; BrowserHive then keeps one outbound connection to Discord (the gateway) while it runs. The wizard's **What's the difference?** panel shows both message styles side by side. + +**Bot mode, step by step:** + +1. **Create the bot.** Open [discord.com/developers](https://discord.com/developers/applications) → **New Application**, name it (for example "BrowserHive") → **Bot** tab → **Reset Token**. Discord shows the token once: copy it and put it in a variable where BrowserHive runs, for example `export BH_DISCORD_BOT_TOKEN='…'` (any name that does not start with `BROWSERHIVE_`), then restart BrowserHive. +2. **Make it private** (recommended, so nobody else can add your bot to their server). The order matters: first **Installation** tab → **Install Link** → set it to **None** → **Save**; only then **Bot** tab → turn **Public Bot** off → **Save**. If you turn Public Bot off while an install link is still set, Discord refuses with *"Private application cannot have a default authorization link. Please check that the default authorization link is set to None in the installation tab."*: set the install link to None, save, and try again. +3. **Leave every Privileged Gateway Intent off** (Bot tab). BrowserHive needs none: button presses arrive without them. +4. **Invite the bot to your server.** In the wizard choose **Bot**; in **Connect**, **Invite the bot** opens Discord with the right link: scope `bot` and exactly the permissions BrowserHive needs, **View Channels, Send Messages, Embed Links, Attach Files**. Pick your server and **Authorize**. The link works for you even though the bot is private, because you own the application. By hand instead: Developer Portal → **OAuth2** → **URL Generator** → scope **bot** only (not `applications.commands` or anything else), **Guild Install** if asked, the four permissions above → open the generated URL → pick your server → **Authorize**. Read Message History is not needed (adding it does no harm). +5. **Pick the channel.** Back in the wizard press **Refresh**, then pick the server and the channel from the lists (the bot fetches them). Without the wizard (a [startup channel](#startup-channels)), you need the channel id: Discord **Settings → Advanced → Developer Mode** on, then right-click the channel → **Copy Channel ID**, and pass `channel=`. +6. **Link your Discord account.** The bot posts a message with a **This is me** button in that channel. Press it within two minutes: your account becomes the first one allowed to answer ([allow-list](#who-may-answer)). The bot deletes that message afterwards. On a startup channel, list the user ids in `allow=` instead (Developer Mode → right-click your name → **Copy User ID**). + +The bot sends the same embed as a webhook, with **Mark resolved**/**Reject** buttons when act buttons are on. The channel card shows the gateway connection: **connected**, **reconnecting** (BrowserHive retries with growing pauses and resumes where Discord allows) or **offline** with the reason, such as a refused token. + +#### Troubleshooting Discord bot mode + +- **"Missing Access" or "Missing Permissions" when sending** (the delivery log shows `Discord 403`): the bot is not in that channel or lacks a permission there. It needs **View Channels** and **Send Messages** to post, **Embed Links** for the embed and **Attach Files** for screenshots; a channel's own permission overrides can take them away even when the server role has them. Re-run **Invite the bot**, or give the bot's role those permissions on that channel. +- **"Unknown Channel"**: the channel was deleted, or the id belongs to another server the bot is not in. Pick the channel again. +- **The card says offline: "Discord refused the bot token."** The token was reset or mistyped. Reset it in the Developer Portal (Bot → Reset Token), update the variable, restart BrowserHive. +- **The card says reconnecting.** Discord or the network dropped the connection; BrowserHive reconnects by itself and resumes the session where it can. Presses made in the meantime fail on Discord's side (next point). +- **"This interaction failed" after pressing a button.** Discord needs an answer within **3 seconds**. BrowserHive answers at once (and finishes slow commands in a follow-up), so this means BrowserHive was not running or not connected at that moment. Unlike Telegram, Discord does not keep the press: press again once the card shows connected. +- **"Invalid Form Body"** in the delivery log names the field Discord refused; please report it as a bug. ### ntfy @@ -94,7 +117,7 @@ Messages are an embed: a coloured bar by severity, the facts as fields, the scre 3. Scan the QR code with your phone (or tap **Subscribe** in the app and enter the server and topic). 4. For a protected server or topic, create an access token in ntfy and put it in a variable such as `BH_NTFY_TOKEN`. -Priority follows severity (a critical notification is urgent). Buttons are "view" actions (at most three). A revision replaces the notification on the phone; a self-destruct deletes it. On ntfy.sh, attachments (screenshots) are stored on the public server for three hours and their links are not documented to be private: for screenshots, a self-hosted ntfy is the better choice. A self-hosted server without an attachment cache refuses uploads; BrowserHive then sends the text alone. +Priority follows severity (a critical notification is urgent). Buttons are "view" actions (at most three); with a [reply topic](#ntfy-a-second-topic-for-answers) they can also answer. A revision replaces the notification on the phone; a self-destruct deletes it. On ntfy.sh, attachments (screenshots) are stored on the public server for three hours and their links are not documented to be private: for screenshots, a self-hosted ntfy is the better choice. A self-hosted server without an attachment cache refuses uploads; BrowserHive then sends the text alone. ### Webhook @@ -115,6 +138,65 @@ The webhook channel POSTs the [message contract](#the-message-contract) itself a A revision is POSTed again with `op: "edit"` and a higher `message.revision`; keep the highest. When you set a signing secret (`secret` from a variable such as `BH_WEBHOOK_SECRET`), each request carries `X-BrowserHive-Timestamp` and `X-BrowserHive-Signature: sha256=`, the HMAC-SHA256 of the raw body with your secret. Only `http:` and `https:` URLs are accepted, redirects are followed only on the same scheme and host, and a private address (your LAN) is allowed with a warning, because the request comes from inside your network. +## Answer from your phone + +With **act buttons** on, a notification that waits for you carries buttons that act, not just links: **Mark resolved** and **Reject** for an attention request, **Approve** and **Deny** for a vault fill. Press one in the chat, and BrowserHive does exactly what the same button in the dashboard does, then edits the message: the buttons disappear and it says who answered and when ("Resolved on Telegram by 123456789 after 42 s"). Everything else (take over, open the live view) stays a link. + +Act buttons are **off** by default. Switch them on per channel in the wizard's **What to send → Answer from the chat**, or with `actButtons=true` on a [startup channel](#startup-channels). + +| Platform | What it needs | How a press reaches BrowserHive | Who may press | +|---|---|---|---| +| Telegram | nothing more | BrowserHive asks Telegram for new events (long polling), one connection per bot | the people on the channel's [allow-list](#who-may-answer) | +| Discord | [bot mode](#discord) | the bot's gateway connection | the people on the allow-list | +| ntfy | a [reply topic](#ntfy-a-second-topic-for-answers) | the phone posts to the reply topic; BrowserHive subscribes to it | whoever can read the notification topic | +| Webhook | a receiver that calls the API | your receiver | whoever holds the API token it uses | + +Every connection goes **out** from your machine; nothing needs a public address. Presses made while BrowserHive is stopped are handled at the next start when the platform kept them (Telegram keeps them for a day, ntfy.sh for 12 hours), and refused when the request has stopped waiting in the meantime. On Discord a press while BrowserHive is stopped shows "This interaction failed". + +### Who may answer + +A button in a chat is a remote control for your agent's browser, so every press is checked before anything runs: + +- **The allow-list.** Only the Telegram or Discord accounts on the channel's allow-list (**Allowed people**) may press. By default that is the person who connected the chat in the wizard: the one who sent `/start` on Telegram, or pressed **This is me** on Discord (the wizard then shows "Connected as "). Anyone else who presses is told, privately (a Telegram notice or a Discord message only they see), that they are not allowed yet and where the admin adds them, with their own id. Their press appears under **Notifications → Actions** as refused, with **Allow this person**: one click (after a confirmation) adds them, and their next press works. You can also add people by numeric user id under **Answer from the chat → Allowed people** (Telegram: the id from the refusal; Discord: Developer Mode on, right-click the person → **Copy User ID**). A startup channel's list is its `allow=` parameter. +- **Single use.** Each button carries a one-time command token (`bh1:` and 11 random characters). It works once, for 24 hours, only in the chat it was sent to, and only while the request still waits. BrowserHive stores only a hash of it. +- **The same rules as the dashboard.** The press runs through the same code as the dashboard's resolve buttons. The agent learns that it was answered from Telegram, Discord or ntfy, but never your chat identity. +- **An audit.** **Notifications → Actions** lists every press of a real button: when, which channel and notification, which button, who pressed it and what happened (done, not allowed, already used, expired, the request had already stopped waiting, pressed in another chat, act buttons off, or failed with the reason). Presses of buttons BrowserHive never created are only counted, so a stranger cannot fill the list. The audit is kept like the other audit records (90 days by default). + +### ntfy: a second topic for answers + +ntfy has no bot and no way to receive a button press, so BrowserHive uses a second, private topic, the **reply topic**. Each act button is an ntfy `http` action: tapping it makes your phone post the button's one-time token to the reply topic on your ntfy server. BrowserHive subscribes to the reply topic, acts, and replaces the notification on your phone with its new state. Android and iOS both support these buttons. At most three buttons fit on an ntfy notification: the answers come first, then the links. + +Set it in the wizard's **Connect** step (a random name is suggested, like the main topic), or `reply=bh-reply-…` (or `reply=env:NAME`) on a startup channel. The reply topic must differ from the notification topic. If reading it needs a token, name its variable in `replyToken` (the channel's own `token` is used otherwise). + +**Security.** ntfy notifications carry no user identity, so there is no allow-list: **whoever can read the notification topic can press its buttons.** Keep the notification topic private: a long random name on ntfy.sh, or access control on your own server. On a self-hosted server, the tidy setup is to let everyone write to the reply topic but not read it, and let BrowserHive read it with a token: + +```sh +ntfy access everyone bh-reply-7f3kq9x2 write-only +ntfy access browserhive bh-reply-7f3kq9x2 read-only +``` + +Someone who learns only the reply topic can post to it (BrowserHive ignores anything that is not a live token), repeat a token that was already used (refused) and see tokens after they were used. They cannot guess a live token (66 random bits) and so cannot act. Putting an access token into the buttons instead would hand a write token to everyone who can read the notification topic, so BrowserHive does not do that. + +### Webhook: answer from your own receiver + +With act buttons on, the webhook channel sends the notification's `act` actions unchanged in the [message contract](#the-message-contract), for example: + +```json +{ "kind": "act", "id": "reject", "label": "Reject", "style": "danger", + "command": { "op": "attention.resolve", "args": { "request_id": "a-…", "decision": "reject" } }, + "confirm": "Reject this request? The agent is told it was rejected.", + "fallback": { "label": "Open in BrowserHive", "path": "/sessions/…?live=1" } } +``` + +There are no tokens in them and BrowserHive opens no callback endpoint. Your receiver answers through the REST API with an operator API token that has the right scope (`attention:resolve`, or `vault:confirm` for vault fills): + +```sh +curl -X POST https://browserhive.example.net/api/v1/attention/a-…/resolve \ + -H "Authorization: Bearer $BH_API_TOKEN" -H 'Content-Type: application/json' \ + -d '{"decision":"reject"}' +# vault fills: POST /api/v1/vault/confirm/{request_id}/resolve {"decision":"approve"|"deny"} +``` + ## Public address Links in a notification ("Take over", "Open session") must open somewhere your phone can reach. By default they point at this computer (`http://127.0.0.1:9876/…`) and are labelled **Open on this computer**; they work on the machine running BrowserHive and nowhere else. @@ -176,7 +258,7 @@ browserhive --admin \ ``` - Secrets are always written `env:NAME`. A token typed into the flag is refused (exit 64), because other users of the machine can read process arguments. -- Rules use the same names as the dashboard: `categories`, `min`, `sessions`, `harness`, `content`, `quiet=22:00-07:00` with `tz`, `ttl.needs-you=2h`, `deleteWhenResolved`, `images`, `maskImages`. Every parameter is listed in the [command-line guide](cli.md#notification-channels). +- Rules use the same names as the dashboard: `categories`, `min`, `sessions`, `harness`, `content`, `quiet=22:00-07:00` with `tz`, `ttl.needs-you=2h`, `deleteWhenResolved`, `images`, `maskImages`, `actButtons=true` and `allow=123456+789012` (the Telegram or Discord user ids allowed to answer; a startup channel has no setup flow, so name them here). A Discord bot is `discord:name=ops,mode=bot,token=env:BH_DISCORD_BOT_TOKEN,channel=`; an ntfy reply topic is `reply=…`. Every parameter is listed in the [command-line guide](cli.md#notification-channels). - The flag has no environment-variable or config-file spelling. Startup channels appear in the dashboard with a **from startup** badge. You can pause them there, but you edit them by changing the flag and restarting. A startup channel whose name a dashboard channel already uses stops the start with an error, so neither silently wins. ## Delivery log @@ -186,7 +268,7 @@ browserhive --admin \ From a terminal: ```sh -browserhive channels list # status, target, variables, last delivery, 24 h counts +browserhive channels list # status, target, variables, answers, last delivery, 24 h counts browserhive channels test phone # a real test message; exit 1 when the platform refused it browserhive channels preview phone --sample vault-confirm # the request a send would make; sends nothing ``` @@ -203,8 +285,8 @@ A channel sends notifications to a service you chose, so each one has a **conten | `titles` (default) | Adds the title, the summary and the facts (session, tool, error code, page address without query string). | | `full` | Adds the agent's own words (the attention reason), longer details, and allows [screenshots](#screenshots). | -At every level, BrowserHive first removes registered secrets and credential-shaped text and strips query strings and fragments from URLs ([security](security.md)). Channel tokens never reach a log line, the database or the delivery log. +At every level, BrowserHive first removes registered secrets and credential-shaped text and strips query strings and fragments from URLs ([security](security.md)). Channel tokens never reach a log line, the database or the delivery log. Act-button tokens exist only in the chat message; the database keeps a hash, and no log, delivery row or preview shows one. ## Retention -Read or dismissed notifications are kept for 30 days, others for 90. Delivery history is kept for 30 days. Configured channels are never pruned; `browserhive purge` lists them with everything else in the database. +Read or dismissed notifications are kept for 30 days, others for 90. Delivery history is kept for 30 days. The act-button audit is kept like the other audit records (`auditRetentionDays`, 90 days by default); used or expired button tokens are removed a day after they expire. Configured channels are never pruned; `browserhive purge` lists them with everything else in the database. diff --git a/docs/guide/security.md b/docs/guide/security.md index b81079e..ead0d34 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -71,6 +71,7 @@ Failures are returned as a status and reason (`origin_mismatch`, `not_authorized - **Pixels are not redacted.** The live view and screenshots show exactly what the browser shows. Screenshot tracing skips frames while a redaction window is open, but operators are trusted with the live view. - Every secret BrowserHive creates or handles (seed password, tokens, cookies, vault session tokens, OTLP headers) is registered with a redactor that scrubs logs, database rows, WebSocket frames, MCP results and OTLP exports. That includes secrets the config file reads from environment variables (`{env:CI_TOKEN}`, see [References](configuration.md#references)): the variable's name is shown so you know what to set, never its value or the file's text around it. - **Notifications are redacted before they are stored or sent.** Every text a notification copies from an event (an agent's attention reason, a page address, an error) goes through the same redactor, and URLs lose their query strings. A notification channel keeps its tokens in environment variables; BrowserHive stores only the variable names, so a database backup never contains one. See [Notifications](notifications.md). +- **Answering from a chat is opt-in and checked.** Act buttons are off per channel until you switch them on. Each button carries a single-use token that expires after 24 hours, works only in the chat it was sent to and only while the request waits; only a hash is stored. On Telegram and Discord only the accounts on the channel's allow-list may press (by default the person who connected the chat). ntfy has no user identity, so its notification topic must stay private. Every press is audited under Notifications → Actions, and the agent never learns who answered. See [Answer from your phone](notifications.md#answer-from-your-phone). - **Channels send only what you allow.** Each channel has a content level (counts, titles or full); screenshots are off by default, need `full`, are never taken during a vault fill and can mask form fields; the webhook channel can sign its requests. Links in notifications never carry a token. See [what leaves your machine](notifications.md#what-leaves-your-machine). ## What is recorded diff --git a/docs/guide/upgrading.md b/docs/guide/upgrading.md index 53d607a..68c73f6 100644 --- a/docs/guide/upgrading.md +++ b/docs/guide/upgrading.md @@ -29,6 +29,8 @@ browserhive db migrate **Each session records whether it ran sandboxed (schema v4).** From 0.2 on, the database stores each session's sandbox state and browser version when its browser launches, so closed sessions keep showing them. The migration only adds columns: an older release still opens the database. Sessions from before the upgrade show "not recorded"; nothing is guessed for them. +**Answering from your phone (schema v6).** Three tables are added: the act-button tokens (hashes only), the audit of button presses, and where each chat connection resumes. Nothing is backfilled and an older release still opens the database. Telegram channels now send Rich Messages; messages sent before the upgrade keep being edited in their old format. See [Answer from your phone](notifications.md#answer-from-your-phone). + **Notifications gain a message contract (schema v5).** The database adds the notification contract's fields to every notification (kind, category, severity, state, revision, thread) and three tables for delivery to chat apps (channels, the delivery outbox and the sent-message index). Existing notifications are classified from what they already recorded; an attention request's notification reads as resolved or expired when the request was. The migration only adds columns and tables: an older release still opens the database. See [Notifications](notifications.md). Prereleases are published under the `next` tag: `bun add -g browserhive@next`. diff --git a/docs/reference/api.md b/docs/reference/api.md index e8620ef..2c66687 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -2,7 +2,7 @@ # REST API reference -The admin REST API served under `/api/v1` on the same port as MCP and the dashboard when `--admin` is on (101 operations), generated from `HTTP_ENDPOINTS` in `@browserhive/contracts/http`. Request and response schemas are in the OpenAPI 3.1 document the server serves at `/api/v1/openapi.json`, with an interactive reference UI at `/api/v1/docs`. +The admin REST API served under `/api/v1` on the same port as MCP and the dashboard when `--admin` is on (106 operations), generated from `HTTP_ENDPOINTS` in `@browserhive/contracts/http`. Request and response schemas are in the OpenAPI 3.1 document the server serves at `/api/v1/openapi.json`, with an interactive reference UI at `/api/v1/docs`. Summaries come from `packages/contracts/generated/openapi.json`. @@ -176,6 +176,11 @@ Summaries come from `packages/contracts/generated/openapi.json`. | GET | `/api/v1/channels/env` | `checkChannelEnv` | `channels:read` | cookie, bearer | Whether each named environment variable is set in the server (never its value). | | POST | `/api/v1/channels/telegram/connect` | `startTelegramConnect` | `channels:write` | cookie, bearer | Start the one-tap Telegram connect: a t.me link and a 2-minute wait for /start. | | GET | `/api/v1/channels/telegram/connect/{connect_id}` | `getTelegramConnect` | `channels:read` | cookie, bearer | State of a Telegram connect: waiting, connected (with the chat), expired or failed. | +| POST | `/api/v1/channels/discord/bot` | `getDiscordBot` | `channels:write` | cookie, bearer | Who the Discord bot is, its invite link (minimal permissions) and the servers it is in. | +| POST | `/api/v1/channels/discord/channels` | `listDiscordChannels` | `channels:write` | cookie, bearer | The text channels of one of the Discord bot's servers (the channel picker). | +| POST | `/api/v1/channels/discord/connect` | `startDiscordConnect` | `channels:write` | cookie, bearer | Link your Discord account: the bot posts a "This is me" button and waits 2 minutes for it. | +| GET | `/api/v1/channels/discord/connect/{connect_id}` | `getDiscordConnect` | `channels:read` | cookie, bearer | State of a Discord account link: waiting, connected (with the user), expired, failed. | +| GET | `/api/v1/channels/actions` | `listChannelActions` | `channels:read` | cookie, bearer | The act-button audit newest first: who pressed what, from which chat, and the outcome. | | GET | `/api/v1/channels/{channel_id}` | `getChannel` | `channels:read` | cookie, bearer | One channel. | | PATCH | `/api/v1/channels/{channel_id}` | `updateChannel` | `channels:write` | cookie, bearer | Edit a dashboard channel (startup channels are read-only). | | DELETE | `/api/v1/channels/{channel_id}` | `deleteChannel` | `channels:write` | cookie, bearer | Delete a dashboard channel and its delivery log. | diff --git a/docs/reference/websocket.md b/docs/reference/websocket.md index 72ccc7c..04f706c 100644 --- a/docs/reference/websocket.md +++ b/docs/reference/websocket.md @@ -138,7 +138,7 @@ Subscribe with `{ "type": "subscribe", "topic": "", "cursor"?: | `system` | `system:read` | [`system.degraded`](#event-system-degraded), [`system.recovered`](#event-system-recovered), [`system.tick`](#event-system-tick), [`system.capacity`](#event-system-capacity), [`retention.completed`](#event-retention-completed) | | `logs` | `logs:read` | [`log.record`](#event-log-record) | | `notifications` | `notifications:read` | [`notification.created`](#event-notification-created), [`notification.updated`](#event-notification-updated) | -| `channels` | `channels:read` | [`channel.changed`](#event-channel-changed), [`channel.removed`](#event-channel-removed), [`delivery.updated`](#event-delivery-updated) | +| `channels` | `channels:read` | [`channel.changed`](#event-channel-changed), [`channel.removed`](#event-channel-removed), [`delivery.updated`](#event-delivery-updated), [`action.recorded`](#event-action-recorded) | | `session:` | `sessions:read` | [`session.opened`](#event-session-opened), [`session.updated`](#event-session-updated), [`session.closed`](#event-session-closed), [`session.removed`](#event-session-removed), [`session.warning`](#event-session-warning), [`tool.called`](#event-tool-called), [`page.visited`](#event-page-visited), [`screenshot.captured`](#event-screenshot-captured), [`vault.access`](#event-vault-access), [`blocklist.hit`](#event-blocklist-hit), [`attention.created`](#event-attention-created), [`attention.resolved`](#event-attention-resolved), [`vault.confirm.created`](#event-vault-confirm-created), [`vault.confirm.resolved`](#event-vault-confirm-resolved) | | `screencast:` | `sessions:read` | stream messages `meta`, `started`, `stopped`, `failed` and binary frames | @@ -341,7 +341,7 @@ Payloads of `kind: "event"` frames, discriminated on `type`. DTO fields (`sessio | Field | Type | Required | Constraints | |---|---|---|---| -| `channel` | `object` | yes | keys `channel_id`, `name`, `kind`, `mode`, `source`, `status`, `target`, `target_hint`, `secret_refs`, `secrets`, `rules`, `capabilities`, `ready`, `problem`, `failure_count`, `last_error`, `last_ok_at`, `last_failure_at`, `created_at`, `updated_at`, `stats` | +| `channel` | `object` | yes | keys `channel_id`, `name`, `kind`, `mode`, `source`, `status`, `target`, `target_hint`, `secret_refs`, `secrets`, `rules`, `capabilities`, `ready`, `problem`, `failure_count`, `last_error`, `last_ok_at`, `last_failure_at`, `created_at`, `updated_at`, `stats`, `connection` | ### `channel.removed` @@ -357,6 +357,13 @@ Payloads of `kind: "event"` frames, discriminated on `type`. DTO fields (`sessio |---|---|---|---| | `delivery` | `object` | yes | keys `seq`, `channel_id`, `channel_name`, `channel_kind`, `notification_id`, `notification_kind`, `notification_title`, `revision`, `op`, `status`, `reason`, `attempts`, `next_attempt_at`, `last_error`, `duration_ms`, `message_ref`, `created_at`, `updated_at` | + +### `action.recorded` + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `action` | `object` | yes | keys `seq`, `at`, `channel_id`, `channel_name`, `channel_kind`, `notification_id`, `notification_title`, `action_id`, `action_label`, `op`, `actor`, `actor_name`, `outcome`, `detail` | + ### `log.record` diff --git a/packages/browserhive/src/cli/commands/channels.ts b/packages/browserhive/src/cli/commands/channels.ts index 538eba0..3352923 100644 --- a/packages/browserhive/src/cli/commands/channels.ts +++ b/packages/browserhive/src/cli/commands/channels.ts @@ -46,6 +46,14 @@ function statusText(channel: ChannelView): string { return channel.source === 'startup' ? `${base} (from startup)` : base; } +/** Whether presses reach BrowserHive (act buttons on): the listener's state, or `—`. */ +function answersText(channel: ChannelView): string { + if (channel.rules.act_buttons !== true) return '—'; + const c = channel.connection; + if (c === null) return channel.kind === 'webhook' ? 'via your receiver' : 'not listening'; + return c.state === 'offline' && c.detail !== null ? `offline (${c.detail})` : c.state; +} + function secretsText(channel: ChannelView): string { if (channel.secrets.length === 0) return '—'; return channel.secrets.map((s) => `${s.env} ${s.set ? '✓' : '✗'}`).join(', '); @@ -81,6 +89,7 @@ export async function runChannelsList( { header: 'STATUS' }, { header: 'SENDS TO' }, { header: 'SECRETS' }, + { header: 'ANSWERS' }, { header: 'LAST DELIVERY' }, { header: '24H SENT/FAILED/SUPPRESSED' }, ], @@ -90,6 +99,7 @@ export async function runChannelsList( statusText(c), c.target_hint, secretsText(c), + answersText(c), c.stats.last_delivery_at === null ? 'never' : `${formatTimestamp(c.stats.last_delivery_at)} ${c.stats.last_status ?? ''}`.trim(), diff --git a/packages/browserhive/src/composition/context.ts b/packages/browserhive/src/composition/context.ts index 36995b3..a3a020d 100644 --- a/packages/browserhive/src/composition/context.ts +++ b/packages/browserhive/src/composition/context.ts @@ -34,6 +34,7 @@ import type { ChannelRegistry, ChannelService, LeaseSweeper, + NotificationActionListeners, NotificationOutbox, NotificationService, OperatorRequestBroker, @@ -117,6 +118,8 @@ export interface DomainPart { readonly notificationOutbox: NotificationOutbox; /** The channels API (spec 03 §4.8.1). */ readonly channelService: ChannelService; + /** Act-button press listeners (Telegram poller, Discord gateway, ntfy reply topic; D-41). */ + readonly actionListeners: NotificationActionListeners; /** The `publicUrl` check (spec 08 §5.8). */ readonly publicUrl: PublicUrlChecker; /** Random per start; `GET /health` reports it (D-37). */ diff --git a/packages/browserhive/src/composition/phases/build-domain.ts b/packages/browserhive/src/composition/phases/build-domain.ts index c2a224f..5c44f1f 100644 --- a/packages/browserhive/src/composition/phases/build-domain.ts +++ b/packages/browserhive/src/composition/phases/build-domain.ts @@ -3,10 +3,14 @@ import { join } from 'node:path'; import { CHANNEL_RENDERERS, + type CursorStore, channelFactories, + createDiscordSetup, createNotificationImageStore, createTelegramSetup, createUrlProbe, + DiscordGatewayHub, + TelegramUpdatesHub, } from '@browserhive/core/notifications'; import type { DomainEvents } from '@browserhive/core/runtime'; import { @@ -15,7 +19,11 @@ import { isInsecurePublicUrl, serializeError, } from '@browserhive/core/runtime'; -import { createPlaywrightPageActions, InProcessEventBus } from '@browserhive/core/server'; +import { + type ActionExecutor, + createPlaywrightPageActions, + InProcessEventBus, +} from '@browserhive/core/server'; import { asyncTick, createTimers } from '../adapters/timers.ts'; import { createAuthStack } from '../auth-stack.ts'; import { type BootContext, part, type SeedNotice } from '../context.ts'; @@ -130,6 +138,44 @@ async function buildDomain( const images = createNotificationImageStore(join(config.dataDir, 'notifications', 'images')); const instanceId = ids.opaque(16); + // Act buttons (D-41): one Telegram poller and one Discord gateway connection per bot token, + // shared by that bot's channels and its setup flow; their offsets live in notification_cursors. + const cursors: CursorStore = { + get: (key) => repos.notificationCursors.get(key), + set: (key, value) => repos.notificationCursors.set(key, value, clock.now()), + }; + const telegramUpdates = new TelegramUpdatesHub({ cursors, logger }); + const discordGateway = new DiscordGatewayHub({ logger }); + undo.push(() => { + telegramUpdates.stop(); + discordGateway.stop(); + }); + // What an act-button press runs: the services behind the dashboard's resolve routes. + const actionExecutors = new Map(); + const attention = operators.attention; + if (attention !== null) { + actionExecutors.set('attention.resolve', { + scope: 'attention:resolve', + async run(args, actor) { + const id = String(args['request_id'] ?? ''); + if (args['decision'] === 'reject') { + await attention.reject(id, actor); + return 'Rejected. The agent was told.'; + } + await attention.resolve(id, actor); + return 'Marked resolved. The agent continues.'; + }, + }); + } + actionExecutors.set('vault.confirm.resolve', { + scope: 'vault:confirm', + async run(args, actor) { + const id = String(args['request_id'] ?? ''); + const approve = args['decision'] === 'approve'; + await operators.vault.resolveConfirm(id, approve ? 'approve' : 'deny', actor); + return approve ? 'Approved. The fill goes ahead.' : 'Denied. Nothing was filled.'; + }, + }); const ops = buildOps({ config, repos, @@ -148,9 +194,18 @@ async function buildDomain( registerSecret: (literal) => secrets.add(literal), dashboardUrl: () => ctx.listeners?.url ?? `http://${config.host}:${config.port}`, deliveryCounter: telemetry.instruments.notificationDeliveries, - channelFactories: channelFactories({ images }), + channelFactories: channelFactories({ + images, + telegramUpdates, + discordGateway, + cursors, + logger, + }), renderers: CHANNEL_RENDERERS, - telegram: createTelegramSetup(), + telegram: createTelegramSetup({ updates: telegramUpdates }), + discord: createDiscordSetup({ gateway: discordGateway }), + actionExecutors, + actionCounter: telemetry.instruments.notificationActions, probe: createUrlProbe(), instanceId, snapshots: createNotificationSnapshots({ @@ -240,6 +295,7 @@ async function buildDomain( channels: ops.channels, notificationOutbox: ops.notificationOutbox, channelService: ops.channelService, + actionListeners: ops.actionListeners, publicUrl: ops.publicUrl, instanceId, preferences: ops.preferences, @@ -258,6 +314,8 @@ async function buildDomain( stopAuthSweep(); stopImagePrune(); ops.channelService.stop(); + telegramUpdates.stop(); + discordGateway.stop(); sessionParts.sweeper.stop(); sessionParts.stopWatcher?.(); await sessions.closeAll('shutdown', Math.max(1_000, deadlineMs - 500)); diff --git a/packages/browserhive/src/composition/phases/domain-ops.ts b/packages/browserhive/src/composition/phases/domain-ops.ts index 2ede8ad..663466f 100644 --- a/packages/browserhive/src/composition/phases/domain-ops.ts +++ b/packages/browserhive/src/composition/phases/domain-ops.ts @@ -4,6 +4,7 @@ import type { ServerConfig } from '@browserhive/contracts/config'; import type { DatabaseHandle, SqliteMaintenanceService } from '@browserhive/core/persistence'; import type { ChannelRenderer, + DiscordSetup, NotificationSnapshots, TelegramSetup, UrlProbe, @@ -31,12 +32,16 @@ import { } from '@browserhive/core/runtime'; import type { OperatorRequestBroker } from '@browserhive/core/server'; import { + type ActionCounter, + type ActionExecutor, type ChannelAdapterFactory, ChannelRegistry, ChannelService, type DeliveryCounter, imageVariants, linkBuilderFor, + NotificationActionListeners, + NotificationActionService, NotificationOutbox, NotificationService, PreferenceService, @@ -75,6 +80,12 @@ export interface OpsInput { /** Screenshot seam (D-36); late-bound because it needs the sessions. */ readonly snapshots?: NotificationSnapshots; readonly telegram?: TelegramSetup; + /** The Discord bot-mode setup calls (D-38). */ + readonly discord?: DiscordSetup; + /** What an act-button press may run (D-41): the same services as the dashboard's routes. */ + readonly actionExecutors?: ReadonlyMap; + /** Counts act-button presses (spec 10 §7). */ + readonly actionCounter?: ActionCounter; /** One-shot URL probe of the `publicUrl` check. */ readonly probe: UrlProbe; /** Random per start (`GET /health`). */ @@ -91,6 +102,10 @@ export interface OpsParts { readonly notificationOutbox: NotificationOutbox; /** The channels API (spec 03 §4.8.1). */ readonly channelService: ChannelService; + /** Act buttons: tokens, presses and the audit (D-41). */ + readonly actions: NotificationActionService; + /** The press listeners (started by `wire-observers`). */ + readonly actionListeners: NotificationActionListeners; /** The `publicUrl` check (spec 08 §5.8). */ readonly publicUrl: PublicUrlChecker; readonly preferences: PreferenceService; @@ -115,6 +130,23 @@ export function buildOps(input: OpsInput): OpsParts { const links = linkBuilderFor(config.publicUrl, input.dashboardUrl); // The feed is late-bound: the channel service is built after the outbox that reports to it. let feed: ChannelService | undefined; + const actions = new NotificationActionService({ + repos, + registry: channels, + clock, + ids, + logger, + executors: input.actionExecutors ?? new Map(), + bus, + redactor: input.redactor, + ...(input.actionCounter !== undefined && { counter: input.actionCounter }), + }); + const actionListeners = new NotificationActionListeners({ + registry: channels, + handler: (press) => actions.press(press), + logger, + onStatus: (channelId) => feed?.scheduleChannel(channelId), + }); const notificationOutbox = new NotificationOutbox({ uow: input.uow, repos, @@ -128,6 +160,7 @@ export function buildOps(input: OpsInput): OpsParts { ...(input.deliveryCounter !== undefined && { counter: input.deliveryCounter }), onDeliveryChange: (channelId, notificationId) => feed?.onDeliveryChange(notificationId, channelId), + actions, }); const channelService = new ChannelService({ repos, @@ -143,6 +176,9 @@ export function buildOps(input: OpsInput): OpsParts { registerSecret: input.registerSecret, redactor: input.redactor, ...(input.telegram !== undefined && { telegram: input.telegram }), + ...(input.discord !== undefined && { discord: input.discord }), + connection: (channelId) => actionListeners.status(channelId), + actions, }); feed = channelService; const publicUrl = new PublicUrlChecker({ @@ -188,6 +224,8 @@ export function buildOps(input: OpsInput): OpsParts { channels, notificationOutbox, channelService, + actions, + actionListeners, publicUrl, preferences: new PreferenceService({ repo: repos.preferences, clock, logger }), retention: new RetentionScheduler({ diff --git a/packages/browserhive/src/composition/phases/wire-observers.ts b/packages/browserhive/src/composition/phases/wire-observers.ts index 8422a09..78b2196 100644 --- a/packages/browserhive/src/composition/phases/wire-observers.ts +++ b/packages/browserhive/src/composition/phases/wire-observers.ts @@ -102,6 +102,7 @@ export async function wireObserversPhase(ctx: BootContext): Promise domain.recorder.start(); domain.notifications.start(); domain.notificationOutbox.start(); + domain.actionListeners.start(); status.start(); domain.retention.start(); domain.outbox.start(); @@ -126,6 +127,7 @@ export async function wireObserversPhase(ctx: BootContext): Promise domain.outbox.stop(); domain.retention.stop(); status.stop(); + domain.actionListeners.stop(); domain.notificationOutbox.stop(); domain.notifications.stop(); }, diff --git a/packages/browserhive/test/cli/channels.test.ts b/packages/browserhive/test/cli/channels.test.ts new file mode 100644 index 0000000..43c97ee --- /dev/null +++ b/packages/browserhive/test/cli/channels.test.ts @@ -0,0 +1,82 @@ +/** @module test/cli/channels.test — `browserhive channels list` over the REST API (spec 08 §7.1): the platform with its Discord mode, the answer state of act buttons (D-41), and `--json`. */ + +import { describe, expect, it } from 'bun:test'; +import type { ChannelView } from '@browserhive/contracts/http'; +import type { CliDeps } from '../../src/cli/deps.ts'; +import { cliHarness } from './helpers.ts'; + +function channel(overrides: Partial): ChannelView { + return { + channel_id: 'nc-000000000001', + name: 'phone', + kind: 'telegram', + mode: null, + source: 'db', + status: 'active', + target: { chat_id: '-100123' }, + target_hint: 'Ops (…0123)', + secret_refs: { token: 'BH_TG_TOKEN' }, + secrets: [{ param: 'token', env: 'BH_TG_TOKEN', set: true }], + rules: {}, + capabilities: null, + ready: true, + problem: null, + failure_count: 0, + last_error: null, + last_ok_at: null, + last_failure_at: null, + created_at: 1, + updated_at: 1, + stats: { + sent_24h: 3, + failed_24h: 0, + suppressed_24h: 1, + pending: 0, + last_delivery_at: null, + last_status: null, + }, + connection: null, + ...overrides, + }; +} + +const CHANNELS: ChannelView[] = [ + channel({ + rules: { act_buttons: true, allow_list: ['42'] }, + connection: { state: 'connected', since: 1, detail: null }, + }), + channel({ + channel_id: 'nc-000000000002', + name: 'ops-bot', + kind: 'discord', + mode: 'bot', + target_hint: 'bot in #browserhive', + rules: { act_buttons: true }, + connection: { state: 'offline', since: 1, detail: 'Discord refused the bot token.' }, + }), + channel({ channel_id: 'nc-000000000003', name: 'pager', kind: 'ntfy', target_hint: 'ntfy.sh/x' }), +]; + +const http: CliDeps['http'] = async () => { + const body = { data: CHANNELS, now: 2 }; + return { status: 200, json: async () => body, text: async () => JSON.stringify(body) }; +}; + +describe('channels list', () => { + it('shows the Discord mode and whether presses reach BrowserHive', async () => { + const result = await cliHarness({ + argv: ['channels', 'list', '--url', 'http://127.0.0.1:9876', '--token', 'bh_operator_x'], + http, + }); + expect(result.code).toBe(0); + expect(result.stdout).toContain('ANSWERS'); + expect(result.stdout).toContain('discord (bot)'); + expect(result.stdout).toContain('connected'); + expect(result.stdout).toContain('offline (Discord refused the bot token.)'); + const json = await cliHarness({ + argv: ['channels', 'list', '--json', '--url', 'http://127.0.0.1:9876', '--token', 'x'], + http, + }); + expect((JSON.parse(json.stdout) as ChannelView[])[1]?.connection?.state).toBe('offline'); + }); +}); diff --git a/packages/contracts/generated/openapi.json b/packages/contracts/generated/openapi.json index b21630d..a68d664 100644 --- a/packages/contracts/generated/openapi.json +++ b/packages/contracts/generated/openapi.json @@ -9657,6 +9657,38 @@ "last_delivery_at", "last_status" ] + }, + "connection": { + "type": [ + "object", + "null" + ], + "properties": { + "state": { + "type": "string", + "enum": [ + "connecting", + "connected", + "reconnecting", + "offline" + ] + }, + "since": { + "type": "integer", + "minimum": 0 + }, + "detail": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "state", + "since", + "detail" + ] } }, "required": [ @@ -9680,7 +9712,8 @@ "last_failure_at", "created_at", "updated_at", - "stats" + "stats", + "connection" ] } }, @@ -10095,6 +10128,38 @@ "last_delivery_at", "last_status" ] + }, + "connection": { + "type": [ + "object", + "null" + ], + "properties": { + "state": { + "type": "string", + "enum": [ + "connecting", + "connected", + "reconnecting", + "offline" + ] + }, + "since": { + "type": "integer", + "minimum": 0 + }, + "detail": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "state", + "since", + "detail" + ] } }, "required": [ @@ -10118,7 +10183,8 @@ "last_failure_at", "created_at", "updated_at", - "stats" + "stats", + "connection" ] } }, @@ -11918,6 +11984,13 @@ "maxLength": 2048 } }, + "secret_refs": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 256 + } + }, "rules": { "type": "object", "properties": { @@ -14064,199 +14137,610 @@ "expires_at" ] }, - "ChannelPatch": { + "DiscordBotInfo": { "type": "object", "properties": { - "name": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]{0,31}$" + "application_id": { + "type": "string" }, - "mode": { - "type": [ - "string", - "null" - ], - "maxLength": 32 + "bot_id": { + "type": "string" }, - "target": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 2048 - } + "bot_username": { + "type": "string" }, - "secret_refs": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 256 - } + "invite_url": { + "type": "string" }, - "rules": { - "type": "object", - "properties": { - "categories": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "needs-you", - "problems", - "wrap-ups", - "reports", - "system" - ] - } - }, - "min_severity": { - "type": "string", - "enum": [ - "info", - "warn", - "error", - "critical" - ] - }, - "sessions": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "maxItems": 32 - }, - "harness": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 32 - }, - "maxItems": 32 - }, - "quiet_hours": { - "type": "object", - "properties": { - "start": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" - }, - "end": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" - }, - "time_zone": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } + "guilds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "required": [ - "start", - "end" - ] - }, - "content": { - "type": "string", - "enum": [ - "counts", - "titles", - "full" - ] - }, - "images": { - "type": "object", - "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" - }, - "reports": { - "type": "boolean" - }, - "system": { - "type": "boolean" - } - } - }, - "mask_images": { - "type": "boolean" - }, - "ttl_ms": { - "type": "object", - "properties": { - "needs-you": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "problems": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "wrap-ups": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "reports": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "system": { - "type": "integer", - "exclusiveMinimum": 0 - } - } - }, - "delete_when_resolved": { - "type": "object", - "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" - }, - "reports": { - "type": "boolean" - }, - "system": { - "type": "boolean" - } + "name": { + "type": "string" } }, - "act_buttons": { - "type": "boolean" - }, - "allow_list": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "maxItems": 32 - } + "required": [ + "id", + "name" + ] } } }, + "required": [ + "application_id", + "bot_id", + "bot_username", + "invite_url", + "guilds" + ] + }, + "DiscordBotRequest": { + "type": "object", + "properties": { + "token_env": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "required": [ + "token_env" + ], "additionalProperties": false }, - "ChannelTestResponse": { + "DiscordChannelsResponse": { "type": "object", "properties": { - "ok": { - "type": "boolean" - }, - "delivery": { + "channels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "text", + "announcement" + ] + }, + "category": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "name", + "type", + "category" + ] + } + } + }, + "required": [ + "channels" + ] + }, + "DiscordChannelsRequest": { + "type": "object", + "properties": { + "token_env": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "guild_id": { + "type": "string", + "pattern": "^\\d{15,21}$" + } + }, + "required": [ + "token_env", + "guild_id" + ], + "additionalProperties": false + }, + "DiscordConnectResponse": { + "type": "object", + "properties": { + "connect_id": { + "type": "string" + }, + "expires_at": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "connect_id", + "expires_at" + ] + }, + "DiscordConnectRequest": { + "type": "object", + "properties": { + "token_env": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "channel_id": { + "type": "string", + "pattern": "^\\d{15,21}$" + } + }, + "required": [ + "token_env", + "channel_id" + ], + "additionalProperties": false + }, + "DiscordConnectStatus": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "waiting", + "connected", + "expired", + "failed" + ] + }, + "user": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "expires_at": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "status", + "user", + "error", + "expires_at" + ] + }, + "ActionsPage": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "seq": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "channel_id": { + "type": "string" + }, + "channel_name": { + "type": "string" + }, + "channel_kind": { + "type": "string" + }, + "notification_id": { + "type": [ + "string", + "null" + ] + }, + "notification_title": { + "type": [ + "string", + "null" + ] + }, + "action_id": { + "type": "string" + }, + "action_label": { + "type": [ + "string", + "null" + ] + }, + "op": { + "type": "string" + }, + "actor": { + "type": "string" + }, + "actor_name": { + "type": [ + "string", + "null" + ] + }, + "outcome": { + "type": "string", + "enum": [ + "done", + "failed", + "not_allowed", + "used", + "expired", + "stale", + "wrong_channel", + "disabled" + ] + }, + "detail": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "seq", + "at", + "channel_id", + "channel_name", + "channel_kind", + "notification_id", + "notification_title", + "action_id", + "action_label", + "op", + "actor", + "actor_name", + "outcome", + "detail" + ] + } + }, + "page": { + "type": "object", + "properties": { + "next_cursor": { + "type": [ + "string", + "null" + ] + }, + "prev_cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "next_cursor", + "limit" + ] + }, + "facets": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "count": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "value", + "count" + ] + } + } + }, + "applied": { + "type": "object", + "properties": { + "filters": { + "type": "object", + "additionalProperties": {} + }, + "sort": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "dir": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + "required": [ + "key", + "dir" + ] + } + }, + "required": [ + "filters", + "sort" + ] + }, + "meta": { + "type": "object", + "properties": { + "now": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "now" + ] + } + }, + "required": [ + "data", + "page", + "applied", + "meta" + ] + }, + "ChannelPatch": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,31}$" + }, + "mode": { + "type": [ + "string", + "null" + ], + "maxLength": 32 + }, + "target": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 2048 + } + }, + "secret_refs": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 256 + } + }, + "rules": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } + }, + "min_severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "sessions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + }, + "harness": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 32 + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" + ] + }, + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "properties": { + "needs-you": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "problems": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "wrap-ups": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "reports": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "system": { + "type": "integer", + "exclusiveMinimum": 0 + } + } + }, + "delete_when_resolved": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + } + } + } + }, + "additionalProperties": false + }, + "ChannelTestResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "delivery": { "type": [ "object", "null" @@ -24634,11 +25118,263 @@ ], "responses": { "200": { - "description": "Notifications newest first with the unread count.", + "description": "Notifications newest first with the unread count.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationsPage" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/notifications/{notification_id}/read": { + "post": { + "operationId": "markNotificationRead", + "tags": [ + "notifications" + ], + "summary": "Mark one notification read.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "notifications:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "required": true, + "name": "notification_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Mark one notification read.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveSessionResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/notifications/read-all": { + "post": { + "operationId": "markAllNotificationsRead", + "tags": [ + "notifications" + ], + "summary": "Mark every notification read.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "notifications:write", + "responses": { + "200": { + "description": "Mark every notification read.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationsUpdatedResponse" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/notifications/{notification_id}": { + "delete": { + "operationId": "dismissNotification", + "tags": [ + "notifications" + ], + "summary": "Dismiss one notification.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "notifications:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "required": true, + "name": "notification_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Dismiss one notification.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationsPage" + "$ref": "#/components/schemas/ArchiveSessionResponse" } } } @@ -24696,13 +25432,13 @@ } } }, - "/api/v1/notifications/{notification_id}/read": { + "/api/v1/notifications/dismiss-all": { "post": { - "operationId": "markNotificationRead", + "operationId": "dismissAllNotifications", "tags": [ "notifications" ], - "summary": "Mark one notification read.", + "summary": "Dismiss every notification.", "security": [ { "cookieAuth": [] @@ -24712,24 +25448,161 @@ } ], "x-browserhive-scope": "notifications:write", - "parameters": [ + "responses": { + "200": { + "description": "Dismiss every notification.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationsUpdatedResponse" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/me/preferences": { + "get": { + "operationId": "getPreferences", + "tags": [ + "preferences" + ], + "summary": "The caller's stored preferences (known keys only).", + "security": [ { - "schema": { - "type": "string", - "pattern": "^n-[A-Za-z0-9_-]{12}$" - }, - "required": true, - "name": "notification_id", - "in": "path" + "cookieAuth": [] + }, + { + "bearerAuth": [] } ], + "x-browserhive-scope": null, "responses": { "200": { - "description": "Mark one notification read.", + "description": "The caller's stored preferences (known keys only).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" + "$ref": "#/components/schemas/PreferencesResponse" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "put": { + "operationId": "putPreferences", + "tags": [ + "preferences" + ], + "summary": "Replace the preferences document (≤ 64 KiB; unknown keys rejected).", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "preferences:write", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutPreferencesRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Replace the preferences document (≤ 64 KiB; unknown keys rejected).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutPreferencesResponse" } } } @@ -24764,6 +25637,16 @@ } } }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -24787,13 +25670,13 @@ } } }, - "/api/v1/notifications/read-all": { - "post": { - "operationId": "markAllNotificationsRead", + "/api/v1/channels": { + "get": { + "operationId": "listChannels", "tags": [ - "notifications" + "channels" ], - "summary": "Mark every notification read.", + "summary": "Every notification channel (dashboard and startup) with its state; never a secret value.", "security": [ { "cookieAuth": [] @@ -24802,14 +25685,14 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "notifications:write", + "x-browserhive-scope": "channels:read", "responses": { "200": { - "description": "Mark every notification read.", + "description": "Every notification channel (dashboard and startup) with its state; never a secret value.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationsUpdatedResponse" + "$ref": "#/components/schemas/ChannelsResponse" } } } @@ -24855,15 +25738,13 @@ } } } - } - }, - "/api/v1/notifications/{notification_id}": { - "delete": { - "operationId": "dismissNotification", + }, + "post": { + "operationId": "createChannel", "tags": [ - "notifications" + "channels" ], - "summary": "Dismiss one notification.", + "summary": "Create a channel. Secrets are environment variable names, never values (D-33).", "security": [ { "cookieAuth": [] @@ -24872,31 +25753,30 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "notifications:write", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^n-[A-Za-z0-9_-]{12}$" - }, - "required": true, - "name": "notification_id", - "in": "path" + "x-browserhive-scope": "channels:write", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelInput" + } + } } - ], + }, "responses": { - "200": { - "description": "Dismiss one notification.", + "201": { + "description": "Create a channel. Secrets are environment variable names, never values (D-33).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" + "$ref": "#/components/schemas/ChannelResponse" } } } }, "400": { - "description": "VALIDATION_FAILED", + "description": "VALIDATION_FAILED, CHANNEL_KIND_UNAVAILABLE", "content": { "application/problem+json": { "schema": { @@ -24915,8 +25795,28 @@ } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "CHANNEL_NAME_TAKEN", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", "content": { "application/problem+json": { "schema": { @@ -24948,13 +25848,13 @@ } } }, - "/api/v1/notifications/dismiss-all": { + "/api/v1/channels/preview": { "post": { - "operationId": "dismissAllNotifications", + "operationId": "previewChannel", "tags": [ - "notifications" + "channels" ], - "summary": "Dismiss every notification.", + "summary": "Render a sample notification exactly as the channel would send it. Sends nothing.", "security": [ { "cookieAuth": [] @@ -24963,14 +25863,34 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "notifications:write", + "x-browserhive-scope": "channels:read", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelPreviewRequest" + } + } + } + }, "responses": { "200": { - "description": "Dismiss every notification.", + "description": "Render a sample notification exactly as the channel would send it. Sends nothing.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationsUpdatedResponse" + "$ref": "#/components/schemas/ChannelPreview" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED, CHANNEL_KIND_UNAVAILABLE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -24995,6 +25915,26 @@ } } }, + "404": { + "description": "CHANNEL_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -25018,13 +25958,13 @@ } } }, - "/api/v1/me/preferences": { + "/api/v1/channels/deliveries": { "get": { - "operationId": "getPreferences", + "operationId": "listDeliveries", "tags": [ - "preferences" + "channels" ], - "summary": "The caller's stored preferences (known keys only).", + "summary": "The delivery log newest first: every send, edit and delete, and why anything was not sent.", "security": [ { "cookieAuth": [] @@ -25033,14 +25973,139 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, + "x-browserhive-scope": "channels:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "required": false, + "name": "channel_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "required": false, + "name": "notification_id", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "send", + "edit", + "delete" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "op", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "kind", + "in": "query" + } + ], "responses": { "200": { - "description": "The caller's stored preferences (known keys only).", + "description": "The delivery log newest first: every send, edit and delete, and why anything was not sent.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PreferencesResponse" + "$ref": "#/components/schemas/DeliveriesPage" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -25086,13 +26151,15 @@ } } } - }, - "put": { - "operationId": "putPreferences", + } + }, + "/api/v1/channels/deliveries/{seq}": { + "get": { + "operationId": "getDelivery", "tags": [ - "preferences" + "channels" ], - "summary": "Replace the preferences document (≤ 64 KiB; unknown keys rejected).", + "summary": "One delivery with the message as that channel is shown it (redacted).", "security": [ { "cookieAuth": [] @@ -25101,24 +26168,25 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "preferences:write", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PutPreferencesRequest" - } - } + "x-browserhive-scope": "channels:read", + "parameters": [ + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "required": true, + "name": "seq", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Replace the preferences document (≤ 64 KiB; unknown keys rejected).", + "description": "One delivery with the message as that channel is shown it (redacted).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PutPreferencesResponse" + "$ref": "#/components/schemas/DeliveryDetailResponse" } } } @@ -25153,8 +26221,8 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "404": { + "description": "DELIVERY_NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -25186,13 +26254,13 @@ } } }, - "/api/v1/channels": { + "/api/v1/channels/env": { "get": { - "operationId": "listChannels", + "operationId": "checkChannelEnv", "tags": [ "channels" ], - "summary": "Every notification channel (dashboard and startup) with its state; never a secret value.", + "summary": "Whether each named environment variable is set in the server (never its value).", "security": [ { "cookieAuth": [] @@ -25201,14 +26269,41 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "channels:read", + "x-browserhive-scope": "channels:read", + "parameters": [ + { + "schema": { + "type": "array", + "items": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "minItems": 1, + "maxItems": 16 + }, + "required": true, + "name": "names", + "in": "query" + } + ], "responses": { "200": { - "description": "Every notification channel (dashboard and startup) with its state; never a secret value.", + "description": "Whether each named environment variable is set in the server (never its value).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChannelsResponse" + "$ref": "#/components/schemas/ChannelEnvResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -25254,13 +26349,15 @@ } } } - }, + } + }, + "/api/v1/channels/telegram/connect": { "post": { - "operationId": "createChannel", + "operationId": "startTelegramConnect", "tags": [ "channels" ], - "summary": "Create a channel. Secrets are environment variable names, never values (D-33).", + "summary": "Start the one-tap Telegram connect: a t.me link and a 2-minute wait for /start.", "security": [ { "cookieAuth": [] @@ -25275,24 +26372,24 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChannelInput" + "$ref": "#/components/schemas/TelegramConnectRequest" } } } }, "responses": { - "201": { - "description": "Create a channel. Secrets are environment variable names, never values (D-33).", + "200": { + "description": "Start the one-tap Telegram connect: a t.me link and a 2-minute wait for /start.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChannelResponse" + "$ref": "#/components/schemas/TelegramConnectResponse" } } } }, "400": { - "description": "VALIDATION_FAILED, CHANNEL_KIND_UNAVAILABLE", + "description": "VALIDATION_FAILED", "content": { "application/problem+json": { "schema": { @@ -25322,7 +26419,7 @@ } }, "409": { - "description": "CHANNEL_NAME_TAKEN", + "description": "CHANNEL_NOT_READY", "content": { "application/problem+json": { "schema": { @@ -25360,17 +26457,27 @@ } } } + }, + "502": { + "description": "CHANNEL_PLATFORM_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v1/channels/preview": { - "post": { - "operationId": "previewChannel", + "/api/v1/channels/telegram/connect/{connect_id}": { + "get": { + "operationId": "getTelegramConnect", "tags": [ "channels" ], - "summary": "Render a sample notification exactly as the channel would send it. Sends nothing.", + "summary": "State of a Telegram connect: waiting, connected (with the chat), expired or failed.", "security": [ { "cookieAuth": [] @@ -25380,29 +26487,30 @@ } ], "x-browserhive-scope": "channels:read", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChannelPreviewRequest" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{8,64}$" + }, + "required": true, + "name": "connect_id", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Render a sample notification exactly as the channel would send it. Sends nothing.", + "description": "State of a Telegram connect: waiting, connected (with the chat), expired or failed.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChannelPreview" + "$ref": "#/components/schemas/TelegramConnectStatus" } } } }, "400": { - "description": "VALIDATION_FAILED, CHANNEL_KIND_UNAVAILABLE", + "description": "VALIDATION_FAILED", "content": { "application/problem+json": { "schema": { @@ -25431,26 +26539,6 @@ } } }, - "404": { - "description": "CHANNEL_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "413": { - "description": "PAYLOAD_TOO_LARGE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -25474,13 +26562,13 @@ } } }, - "/api/v1/channels/deliveries": { - "get": { - "operationId": "listDeliveries", + "/api/v1/channels/discord/bot": { + "post": { + "operationId": "getDiscordBot", "tags": [ "channels" ], - "summary": "The delivery log newest first: every send, edit and delete, and why anything was not sent.", + "summary": "Who the Discord bot is, its invite link (minimal permissions) and the servers it is in.", "security": [ { "cookieAuth": [] @@ -25489,135 +26577,50 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "channels:read", - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" - }, - "required": false, - "name": "channel_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^n-[A-Za-z0-9_-]{12}$" - }, - "required": false, - "name": "notification_id", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "pending", - "sending", - "sent", - "retrying", - "dead", - "suppressed", - "superseded" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "status", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "send", - "edit", - "delete" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "op", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "attention.requested", - "vault.confirm", - "vault.filled", - "session.finished", - "session.crashed", - "session.reaped", - "tool.errors", - "system.degraded", - "channel.broken", - "digest.daily", - "report.anomaly", - "test" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "kind", - "in": "query" + "x-browserhive-scope": "channels:write", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscordBotRequest" + } + } } - ], + }, "responses": { "200": { - "description": "The delivery log newest first: every send, edit and delete, and why anything was not sent.", + "description": "Who the Discord bot is, its invite link (minimal permissions) and the servers it is in.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeliveriesPage" + "$ref": "#/components/schemas/DiscordBotInfo" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "VALIDATION_FAILED", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -25626,8 +26629,8 @@ } } }, - "401": { - "description": "UNAUTHORIZED", + "409": { + "description": "CHANNEL_NOT_READY", "content": { "application/problem+json": { "schema": { @@ -25636,8 +26639,8 @@ } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "413": { + "description": "PAYLOAD_TOO_LARGE", "content": { "application/problem+json": { "schema": { @@ -25665,17 +26668,27 @@ } } } + }, + "502": { + "description": "CHANNEL_PLATFORM_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v1/channels/deliveries/{seq}": { - "get": { - "operationId": "getDelivery", + "/api/v1/channels/discord/channels": { + "post": { + "operationId": "listDiscordChannels", "tags": [ "channels" ], - "summary": "One delivery with the message as that channel is shown it (redacted).", + "summary": "The text channels of one of the Discord bot's servers (the channel picker).", "security": [ { "cookieAuth": [] @@ -25684,25 +26697,24 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "channels:read", - "parameters": [ - { - "schema": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "required": true, - "name": "seq", - "in": "path" + "x-browserhive-scope": "channels:write", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscordChannelsRequest" + } + } } - ], + }, "responses": { "200": { - "description": "One delivery with the message as that channel is shown it (redacted).", + "description": "The text channels of one of the Discord bot's servers (the channel picker).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeliveryDetailResponse" + "$ref": "#/components/schemas/DiscordChannelsResponse" } } } @@ -25737,8 +26749,18 @@ } } }, - "404": { - "description": "DELIVERY_NOT_FOUND", + "409": { + "description": "CHANNEL_NOT_READY", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", "content": { "application/problem+json": { "schema": { @@ -25766,17 +26788,27 @@ } } } + }, + "502": { + "description": "CHANNEL_PLATFORM_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v1/channels/env": { - "get": { - "operationId": "checkChannelEnv", + "/api/v1/channels/discord/connect": { + "post": { + "operationId": "startDiscordConnect", "tags": [ "channels" ], - "summary": "Whether each named environment variable is set in the server (never its value).", + "summary": "Link your Discord account: the bot posts a \"This is me\" button and waits 2 minutes for it.", "security": [ { "cookieAuth": [] @@ -25785,31 +26817,24 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "channels:read", - "parameters": [ - { - "schema": { - "type": "array", - "items": { - "type": "string", - "maxLength": 128, - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - }, - "minItems": 1, - "maxItems": 16 - }, - "required": true, - "name": "names", - "in": "query" + "x-browserhive-scope": "channels:write", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscordConnectRequest" + } + } } - ], + }, "responses": { "200": { - "description": "Whether each named environment variable is set in the server (never its value).", + "description": "Link your Discord account: the bot posts a \"This is me\" button and waits 2 minutes for it.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChannelEnvResponse" + "$ref": "#/components/schemas/DiscordConnectResponse" } } } @@ -25844,6 +26869,26 @@ } } }, + "409": { + "description": "CHANNEL_NOT_READY", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -25863,17 +26908,27 @@ } } } + }, + "502": { + "description": "CHANNEL_PLATFORM_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v1/channels/telegram/connect": { - "post": { - "operationId": "startTelegramConnect", + "/api/v1/channels/discord/connect/{connect_id}": { + "get": { + "operationId": "getDiscordConnect", "tags": [ "channels" ], - "summary": "Start the one-tap Telegram connect: a t.me link and a 2-minute wait for /start.", + "summary": "State of a Discord account link: waiting, connected (with the user), expired, failed.", "security": [ { "cookieAuth": [] @@ -25882,24 +26937,25 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "channels:write", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TelegramConnectRequest" - } - } + "x-browserhive-scope": "channels:read", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{8,64}$" + }, + "required": true, + "name": "connect_id", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Start the one-tap Telegram connect: a t.me link and a 2-minute wait for /start.", + "description": "State of a Discord account link: waiting, connected (with the user), expired, failed.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TelegramConnectResponse" + "$ref": "#/components/schemas/DiscordConnectStatus" } } } @@ -25934,26 +26990,6 @@ } } }, - "409": { - "description": "CHANNEL_NOT_READY", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "413": { - "description": "PAYLOAD_TOO_LARGE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -25973,27 +27009,17 @@ } } } - }, - "502": { - "description": "CHANNEL_PLATFORM_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } } } } }, - "/api/v1/channels/telegram/connect/{connect_id}": { + "/api/v1/channels/actions": { "get": { - "operationId": "getTelegramConnect", + "operationId": "listChannelActions", "tags": [ "channels" ], - "summary": "State of a Telegram connect: waiting, connected (with the chat), expired or failed.", + "summary": "The act-button audit newest first: who pressed what, from which chat, and the outcome.", "security": [ { "cookieAuth": [] @@ -26007,20 +27033,76 @@ { "schema": { "type": "string", - "pattern": "^[A-Za-z0-9_-]{8,64}$" + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" }, - "required": true, - "name": "connect_id", - "in": "path" + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "required": false, + "name": "channel_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "required": false, + "name": "notification_id", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "done", + "failed", + "not_allowed", + "used", + "expired", + "stale", + "wrong_channel", + "disabled" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "outcome", + "in": "query" } ], "responses": { "200": { - "description": "State of a Telegram connect: waiting, connected (with the chat), expired or failed.", + "description": "The act-button audit newest first: who pressed what, from which chat, and the outcome.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TelegramConnectStatus" + "$ref": "#/components/schemas/ActionsPage" } } } diff --git a/packages/contracts/src/enums/index.ts b/packages/contracts/src/enums/index.ts index 80b4995..f8273ce 100644 --- a/packages/contracts/src/enums/index.ts +++ b/packages/contracts/src/enums/index.ts @@ -17,6 +17,7 @@ export { LogColor } from './log-color.ts'; export { LogFormat } from './log-format.ts'; export { LogLevel } from './log-level.ts'; export { LogPersist } from './log-persist.ts'; +export { NotificationActionOutcome } from './notification-action-outcome.ts'; export { NotificationCategory } from './notification-category.ts'; export { NotificationChannelKind } from './notification-channel-kind.ts'; export { NotificationChannelSource } from './notification-channel-source.ts'; @@ -26,6 +27,7 @@ export { NotificationContentLevel } from './notification-content-level.ts'; export { NotificationDeliveryOp } from './notification-delivery-op.ts'; export { NotificationDeliveryStatus } from './notification-delivery-status.ts'; export { NotificationKind } from './notification-kind.ts'; +export { NotificationListenerState } from './notification-listener-state.ts'; export { NotificationSeverity } from './notification-severity.ts'; export { NotificationState } from './notification-state.ts'; export { NotificationType } from './notification-type.ts'; diff --git a/packages/contracts/src/enums/notification-action-outcome.ts b/packages/contracts/src/enums/notification-action-outcome.ts new file mode 100644 index 0000000..9d81e45 --- /dev/null +++ b/packages/contracts/src/enums/notification-action-outcome.ts @@ -0,0 +1,20 @@ +/** @module contracts/enums/notification-action-outcome — NotificationActionOutcome enum: How an act-button press ended (D-41); one audit row per press of a known token. */ + +import { z } from 'zod'; + +/** + * How an act-button press ended (D-41): `done` ran the command; `failed` ran it and it failed; + * the others refused it before running anything. + */ +export const NotificationActionOutcome = z.enum([ + 'done', + 'failed', + 'not_allowed', + 'used', + 'expired', + 'stale', + 'wrong_channel', + 'disabled', +]); +/** Union of {@link NotificationActionOutcome} members. */ +export type NotificationActionOutcome = z.infer; diff --git a/packages/contracts/src/enums/notification-listener-state.ts b/packages/contracts/src/enums/notification-listener-state.ts new file mode 100644 index 0000000..95e5e25 --- /dev/null +++ b/packages/contracts/src/enums/notification-listener-state.ts @@ -0,0 +1,13 @@ +/** @module contracts/enums/notification-listener-state — NotificationListenerState enum: State of a channel's press listener (Telegram poller, Discord gateway, ntfy reply subscription; D-41). */ + +import { z } from 'zod'; + +/** State of a channel's press listener (the Telegram poller, the Discord gateway, the ntfy reply subscription; D-41). */ +export const NotificationListenerState = z.enum([ + 'connecting', + 'connected', + 'reconnecting', + 'offline', +]); +/** Union of {@link NotificationListenerState} members. */ +export type NotificationListenerState = z.infer; diff --git a/packages/contracts/src/http/channels.ts b/packages/contracts/src/http/channels.ts index d739bab..abf953c 100644 --- a/packages/contracts/src/http/channels.ts +++ b/packages/contracts/src/http/channels.ts @@ -1,12 +1,14 @@ /** @module contracts/http/channels — notification channels: CRUD, test send, preview, the delivery log, the environment check and the Telegram connect flow (spec 03 §4.8.1, D-33, D-37, D-38, D-39) */ import { z } from 'zod'; import { + NotificationActionOutcome, NotificationChannelKind, NotificationChannelSource, NotificationChannelStatus, NotificationDeliveryOp, NotificationDeliveryStatus, NotificationKind, + NotificationListenerState, } from '../enums/index.ts'; import { NotificationId } from '../ids/index.ts'; import { @@ -66,6 +68,17 @@ export const ChannelStats = z.object({ /** Delivery counts of one channel. */ export type ChannelStats = z.infer; +/** State of a channel's press listener (Telegram poller, Discord gateway, ntfy reply subscription; D-41). */ +export const ChannelConnection = z.object({ + state: NotificationListenerState, + /** When the listener entered this state. */ + since: EpochMs, + /** Why it is offline or reconnecting ("the token was refused"); `null` when connected. */ + detail: z.string().nullable(), +}); +/** Press listener state. */ +export type ChannelConnection = z.infer; + /** One configured channel as the API shows it. Never carries a secret value. */ export const ChannelView = z.object({ channel_id: ChannelId, @@ -94,6 +107,8 @@ export const ChannelView = z.object({ created_at: EpochMs, updated_at: EpochMs, stats: ChannelStats, + /** The press listener, or `null` when the channel receives no presses (act buttons off). */ + connection: ChannelConnection.nullable(), }); /** One configured channel. */ export type ChannelView = z.infer; @@ -107,7 +122,7 @@ export type ChannelIdParams = z.infer; export const ChannelInput = z.strictObject({ name: NotificationChannelName, kind: AvailableChannelKind, - /** Discord: `webhook` (default) or `bot` (not available yet, D-38). */ + /** Discord: `webhook` (default) or `bot` (D-38). */ mode: z.string().max(32).nullable().optional(), target: NotificationChannelTarget.default({}), secret_refs: z.record(z.string().max(64), z.string().max(256)).default({}), @@ -209,6 +224,12 @@ export const ChannelPreviewRequest = z kind: AvailableChannelKind.optional(), mode: z.string().max(32).nullable().optional(), target: NotificationChannelTarget.optional(), + /** + * A draft's secret parameters as variable NAMES (never values): decide capabilities (an ntfy + * reply topic from a variable) and show the names in the rendered paths. Entries that are not + * valid variable names are ignored. + */ + secret_refs: z.record(z.string().max(64), z.string().max(256)).optional(), rules: NotificationChannelRules.optional(), sample: PreviewSample.default('attention'), }) @@ -317,3 +338,116 @@ export const TelegramConnectStatus = z.object({ }); /** `GET /channels/telegram/connect/{connect_id}` response. */ export type TelegramConnectStatus = z.infer; + +/** One act-button press (the audit, D-41). Never carries a token. */ +export const ActionRow = z.object({ + seq: z.number().int().positive(), + at: EpochMs, + channel_id: z.string(), + channel_name: z.string(), + channel_kind: z.string(), + notification_id: z.string().nullable(), + notification_title: z.string().nullable(), + action_id: z.string(), + action_label: z.string().nullable(), + op: z.string(), + /** `telegram:`, `discord:` or `ntfy:topic-b`. */ + actor: z.string(), + actor_name: z.string().nullable(), + outcome: NotificationActionOutcome, + /** The answer shown to the presser, or the failure. */ + detail: z.string().nullable(), +}); +/** One act-button press. */ +export type ActionRow = z.infer; + +/** `GET /channels/actions` query (keyset on `seq`, newest first). */ +export const ActionsQuery = z.strictObject({ + cursor: Cursor.optional(), + limit: limitQuery(200, 50), + channel_id: ChannelId.optional(), + notification_id: NotificationId.optional(), + outcome: csv(NotificationActionOutcome), +}); +/** `GET /channels/actions` query. */ +export type ActionsQuery = z.infer; + +/** `GET /channels/actions` body. */ +export const ActionsPage = page(ActionRow); +/** `GET /channels/actions` body. */ +export type ActionsPage = z.infer; + +/** `POST /channels/discord/bot` body. */ +export const DiscordBotRequest = z.strictObject({ token_env: SecretEnvName }); +/** `POST /channels/discord/bot` body. */ +export type DiscordBotRequest = z.infer; + +/** A Discord server the bot is in. */ +export const DiscordGuild = z.object({ id: z.string(), name: z.string() }); +/** A Discord server. */ +export type DiscordGuild = z.infer; + +/** `POST /channels/discord/bot` response: who the bot is, its invite link and its servers. */ +export const DiscordBotInfo = z.object({ + application_id: z.string(), + bot_id: z.string(), + bot_username: z.string(), + /** Adds the bot to a server with the minimal permissions (D-38). */ + invite_url: z.string(), + guilds: z.array(DiscordGuild), +}); +/** `POST /channels/discord/bot` response. */ +export type DiscordBotInfo = z.infer; + +/** `POST /channels/discord/channels` body. */ +export const DiscordChannelsRequest = z.strictObject({ + token_env: SecretEnvName, + guild_id: z.string().regex(/^\d{15,21}$/, 'a Discord id'), +}); +/** `POST /channels/discord/channels` body. */ +export type DiscordChannelsRequest = z.infer; + +/** A text channel of a Discord server. */ +export const DiscordTextChannel = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(['text', 'announcement']), + /** The category it is listed under, if any. */ + category: z.string().nullable(), +}); +/** A text channel. */ +export type DiscordTextChannel = z.infer; + +/** `POST /channels/discord/channels` response. */ +export const DiscordChannelsResponse = z.object({ channels: z.array(DiscordTextChannel) }); +/** `POST /channels/discord/channels` response. */ +export type DiscordChannelsResponse = z.infer; + +/** `POST /channels/discord/connect` body. */ +export const DiscordConnectRequest = z.strictObject({ + token_env: SecretEnvName, + channel_id: z.string().regex(/^\d{15,21}$/, 'a Discord id'), +}); +/** `POST /channels/discord/connect` body. */ +export type DiscordConnectRequest = z.infer; + +/** `POST /channels/discord/connect` response. */ +export const DiscordConnectResponse = z.object({ connect_id: z.string(), expires_at: EpochMs }); +/** `POST /channels/discord/connect` response. */ +export type DiscordConnectResponse = z.infer; + +/** Path params `{connect_id}` of the Discord connect flow. */ +export const DiscordConnectParams = z.strictObject({ + connect_id: z.string().regex(/^[A-Za-z0-9_-]{8,64}$/), +}); + +/** `GET /channels/discord/connect/{connect_id}` response. */ +export const DiscordConnectStatus = z.object({ + status: z.enum(['waiting', 'connected', 'expired', 'failed']), + /** Who pressed "This is me" (the first allow-list entry). */ + user: z.object({ id: z.string(), name: z.string() }).nullable(), + error: z.string().nullable(), + expires_at: EpochMs, +}); +/** `GET /channels/discord/connect/{connect_id}` response. */ +export type DiscordConnectStatus = z.infer; diff --git a/packages/contracts/src/http/endpoints.ts b/packages/contracts/src/http/endpoints.ts index a0b71b5..1669b4c 100644 --- a/packages/contracts/src/http/endpoints.ts +++ b/packages/contracts/src/http/endpoints.ts @@ -143,6 +143,11 @@ export const HTTP_ENDPOINTS: readonly HttpEndpoint[] = [ ep('checkChannelEnv', 'get', '/channels/env', 'channels:read'), ep('startTelegramConnect', 'post', '/channels/telegram/connect', 'channels:write'), ep('getTelegramConnect', 'get', '/channels/telegram/connect/{connect_id}', 'channels:read'), + ep('getDiscordBot', 'post', '/channels/discord/bot', 'channels:write'), + ep('listDiscordChannels', 'post', '/channels/discord/channels', 'channels:write'), + ep('startDiscordConnect', 'post', '/channels/discord/connect', 'channels:write'), + ep('getDiscordConnect', 'get', '/channels/discord/connect/{connect_id}', 'channels:read'), + ep('listChannelActions', 'get', '/channels/actions', 'channels:read'), ep('getChannel', 'get', '/channels/{channel_id}', 'channels:read'), ep('updateChannel', 'patch', '/channels/{channel_id}', 'channels:write'), ep('deleteChannel', 'delete', '/channels/{channel_id}', 'channels:write'), diff --git a/packages/contracts/src/http/index.ts b/packages/contracts/src/http/index.ts index 80b7f89..2925037 100644 --- a/packages/contracts/src/http/index.ts +++ b/packages/contracts/src/http/index.ts @@ -71,7 +71,11 @@ export { ReloadBlocklistResponse, } from './blocklist.ts'; export { + ActionRow, + ActionsPage, + ActionsQuery, ChannelCapabilitiesDto, + ChannelConnection, ChannelEnvQuery, ChannelEnvResponse, ChannelId, @@ -91,6 +95,16 @@ export { DeliveryDetailResponse, DeliveryRow, DeliverySeqParams, + DiscordBotInfo, + DiscordBotRequest, + DiscordChannelsRequest, + DiscordChannelsResponse, + DiscordConnectParams, + DiscordConnectRequest, + DiscordConnectResponse, + DiscordConnectStatus, + DiscordGuild, + DiscordTextChannel, PlatformRequest, TelegramConnectParams, TelegramConnectRequest, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index b9de800..fd5222a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -129,6 +129,7 @@ export { LogFormat, LogLevel, LogPersist, + NotificationActionOutcome, NotificationCategory, NotificationChannelKind, NotificationChannelSource, @@ -138,6 +139,7 @@ export { NotificationDeliveryOp, NotificationDeliveryStatus, NotificationKind, + NotificationListenerState, NotificationSeverity, NotificationState, NotificationType, @@ -236,6 +238,9 @@ export { ACTIVITY_BUCKET_MIN_MS, ACTIVITY_DEFAULT_WINDOW_MS, ACTIVITY_MAX_BUCKETS, + ActionRow, + ActionsPage, + ActionsQuery, ActivityBucket, ActivityGroupBy, ActivityQuery, @@ -282,6 +287,7 @@ export { ChangePasswordRequest, ChangePasswordResponse, ChannelCapabilitiesDto, + ChannelConnection, ChannelEnvQuery, ChannelEnvResponse, ChannelId, @@ -313,6 +319,16 @@ export { DeliveryDetailResponse, DeliveryRow, DeliverySeqParams, + DiscordBotInfo, + DiscordBotRequest, + DiscordChannelsRequest, + DiscordChannelsResponse, + DiscordConnectParams, + DiscordConnectRequest, + DiscordConnectResponse, + DiscordConnectStatus, + DiscordGuild, + DiscordTextChannel, DomainCount, DurationMs, Engine, @@ -690,6 +706,7 @@ export { WaitUntil, } from './tools/index.ts'; export { + ActionRecordedEvent, AttentionCreatedEvent, AttentionResolvedEvent, BlocklistHitEvent, diff --git a/packages/contracts/src/notifications/index.ts b/packages/contracts/src/notifications/index.ts index 17d581b..5596e2d 100644 --- a/packages/contracts/src/notifications/index.ts +++ b/packages/contracts/src/notifications/index.ts @@ -53,6 +53,11 @@ export { TextBlock, } from './message.ts'; export { + ACTION_OUTCOME_TEXT, + ACTION_PAYLOAD_RE, + ACTION_TOKEN_LENGTH, + ACTION_TOKEN_PREFIX, + ACTION_TOKEN_TTL_MS, AVAILABLE_CHANNEL_KINDS, AVAILABLE_DISCORD_MODES, AvailableChannelKind, @@ -61,15 +66,20 @@ export { type ChannelConfigProblem, type ChannelKindSpec, checkChannelConfig, + checkChannelRules, DELIVERY_REASON_TEXT, + DISCORD_BOT_PERMISSIONS, DISCORD_MODES, deliveryReasonText, + discordInviteUrl, + hasPresserIdentity, looksLikeSecretValue, NTFY_DEFAULT_SERVER, PREVIEW_SAMPLE_LABEL, PREVIEW_SAMPLES, PreviewSample, type SecretParamSpec, + supportsActButtons, type TargetKeySpec, TELEGRAM_DELETE_WINDOW_MS, TELEGRAM_TTL_MAX_MS, diff --git a/packages/contracts/src/notifications/platforms.ts b/packages/contracts/src/notifications/platforms.ts index 58a2d9c..5a2bf4f 100644 --- a/packages/contracts/src/notifications/platforms.ts +++ b/packages/contracts/src/notifications/platforms.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import type { NotificationCategory } from '../enums/notification-category.ts'; -import { RESERVED_ENV_PREFIX } from './channel.ts'; +import { type NotificationChannelRules, RESERVED_ENV_PREFIX } from './channel.ts'; /** Platforms that have an adapter (N1). The other `NotificationChannelKind` members are reserved. */ export const AVAILABLE_CHANNEL_KINDS = ['telegram', 'discord', 'ntfy', 'webhook'] as const; @@ -11,10 +11,35 @@ export const AvailableChannelKind = z.enum(AVAILABLE_CHANNEL_KINDS); /** A platform with an adapter. */ export type AvailableChannelKind = z.infer; -/** Discord channel modes (D-38). `bot` is reserved until act buttons ship (N2). */ +/** Discord channel modes (D-38): one per channel. */ export const DISCORD_MODES = ['webhook', 'bot'] as const; -/** Discord modes a channel may be saved with today. */ -export const AVAILABLE_DISCORD_MODES: readonly string[] = ['webhook']; +/** Discord modes a channel may be saved with. */ +export const AVAILABLE_DISCORD_MODES: readonly string[] = DISCORD_MODES; + +/** + * Permissions the Discord bot's invite link asks for (D-38): View Channel (1 << 10), Send Messages + * (1 << 11), Embed Links (1 << 14) and Attach Files (1 << 15). Nothing else is needed: a bot edits + * and deletes its own messages, and interactions need no permission. + */ +export const DISCORD_BOT_PERMISSIONS = 52_224; + +/** + * The invite link that adds a bot to a server with {@link DISCORD_BOT_PERMISSIONS}. + * + * @returns `https://discord.com/oauth2/authorize?…`. + */ +export function discordInviteUrl(applicationId: string): string { + return `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(applicationId)}&scope=bot&permissions=${DISCORD_BOT_PERMISSIONS}`; +} + +/** Prefix of the payload an act button carries (`bh1:`, D-41). */ +export const ACTION_TOKEN_PREFIX = 'bh1:'; +/** Characters of a command token (URL-safe, 6 bits each: 66 random bits). */ +export const ACTION_TOKEN_LENGTH = 11; +/** A command token expires this long after it was minted (Telegram keeps presses 24 h). */ +export const ACTION_TOKEN_TTL_MS = 24 * 60 * 60_000; +/** A button payload: `bh1:` and the token. */ +export const ACTION_PAYLOAD_RE = /^bh1:([A-Za-z0-9_-]{11})$/; /** Telegram lets a bot delete its own messages for 48 hours; setups cap TTLs one hour below (D-35). */ export const TELEGRAM_TTL_MAX_MS = 47 * 60 * 60_000; @@ -31,11 +56,17 @@ export interface TargetKeySpec { readonly param: string; readonly required: boolean; readonly describe: string; + /** The key belongs to this mode only (Discord); `required` applies in that mode. */ + readonly mode?: string; } /** One secret parameter of a platform: stored as an environment variable name (D-33). */ export interface SecretParamSpec { readonly param: string; + /** Startup flag parameter when it differs from `param` (`reply` for `reply_topic`). */ + readonly flag?: string; + /** The parameter belongs to this mode only (Discord); `required` applies in that mode. */ + readonly mode?: string; readonly required: boolean; /** Variable name the setup suggests (never `BROWSERHIVE_*`). */ readonly suggestedEnv: string; @@ -96,14 +127,45 @@ export const CHANNEL_KIND_SPECS: { readonly [K in AvailableChannelKind]: Channel label: 'Discord', modes: DISCORD_MODES, defaultMode: 'webhook', - target: [], + target: [ + { + key: 'channel_id', + param: 'channel', + required: true, + mode: 'bot', + describe: 'Channel id the bot posts in.', + }, + { key: 'guild_id', param: 'guild', required: false, mode: 'bot', describe: 'Server id.' }, + { + key: 'guild_name', + param: 'guildName', + required: false, + mode: 'bot', + describe: 'Name of the server.', + }, + { + key: 'channel_name', + param: 'channelName', + required: false, + mode: 'bot', + describe: 'Name of the channel.', + }, + ], secrets: [ { param: 'webhook', + mode: 'webhook', required: true, suggestedEnv: 'BH_DISCORD_WEBHOOK', describe: 'The webhook URL (Channel settings → Integrations → Webhooks).', }, + { + param: 'token', + mode: 'bot', + required: true, + suggestedEnv: 'BH_DISCORD_BOT_TOKEN', + describe: 'The bot token (Developer Portal → your application → Bot → Reset Token).', + }, ], eitherTargetOrSecret: [], }, @@ -125,6 +187,12 @@ export const CHANNEL_KIND_SPECS: { readonly [K in AvailableChannelKind]: Channel required: false, describe: 'Topic name. On a public server the topic acts as a password.', }, + { + key: 'reply_topic', + param: 'reply', + required: false, + describe: 'Reply topic that act buttons post to (answer from the notification).', + }, ], secrets: [ { @@ -139,6 +207,20 @@ export const CHANNEL_KIND_SPECS: { readonly [K in AvailableChannelKind]: Channel suggestedEnv: 'BH_NTFY_TOPIC', describe: 'Topic name kept in a variable instead of the database.', }, + { + param: 'reply_topic', + flag: 'reply', + required: false, + suggestedEnv: 'BH_NTFY_REPLY_TOPIC', + describe: 'Reply topic kept in a variable instead of the database.', + }, + { + param: 'reply_token', + flag: 'replyToken', + required: false, + suggestedEnv: 'BH_NTFY_REPLY_TOKEN', + describe: 'Access token that reads the reply topic (default: the channel token).', + }, ], eitherTargetOrSecret: ['topic'], }, @@ -171,6 +253,8 @@ const HTTP_RE = /^https?:\/\/[^\s/?#@]+(?::\d{1,5})?(?:\/[^\s?#]*)?$/i; const TOPIC_RE = /^[A-Za-z0-9_-]{1,64}$/; const CHAT_RE = /^-?\d{1,20}$|^@[A-Za-z0-9_]{5,32}$/; const THREAD_RE = /^\d{1,12}$/; +const SNOWFLAKE_RE = /^\d{15,21}$/; +const USER_ID_RE = /^\d{1,21}$/; /** * Whether `value` could be a secret value typed where a variable name belongs: anything that is @@ -212,11 +296,23 @@ export function checkChannelConfig(input: { } else if (input.mode !== null && !spec.modes.includes(input.mode)) { problems.push({ field: 'mode', message: `mode must be one of: ${spec.modes.join(', ')}.` }); } + const requested = input.mode ?? spec.defaultMode; + // An invalid mode is reported once; the per-mode checks then do not apply. + const mode = requested !== null && spec.modes?.includes(requested) ? requested : null; + const inMode = (owner: string | undefined) => + owner === undefined || mode === null || owner === mode; const targetKeys = new Set(spec.target.map((t) => t.key)); for (const key of Object.keys(input.target)) { if (!targetKeys.has(key)) { problems.push({ field: `target.${key}`, message: `unknown ${spec.label} setting '${key}'.` }); } + const owner = spec.target.find((t) => t.key === key)?.mode; + if (!inMode(owner) && (input.target[key] ?? '') !== '') { + problems.push({ + field: `target.${key}`, + message: `${key} belongs to ${owner} mode; remove it in ${mode} mode.`, + }); + } } const secretParams = new Set(spec.secrets.map((s) => s.param)); for (const [param, env] of Object.entries(input.secretRefs)) { @@ -227,6 +323,14 @@ export function checkChannelConfig(input: { }); continue; } + const owner = spec.secrets.find((s) => s.param === param)?.mode; + if (!inMode(owner)) { + problems.push({ + field: `secret_refs.${param}`, + message: `${param} belongs to ${owner} mode; remove it in ${mode} mode.`, + }); + continue; + } if (looksLikeSecretValue(env)) { problems.push({ field: `secret_refs.${param}`, @@ -240,12 +344,20 @@ export function checkChannelConfig(input: { } } for (const t of spec.target) { - if (t.required && (input.target[t.key] ?? '') === '') { + if ( + t.required && + (t.mode === undefined || t.mode === mode) && + (input.target[t.key] ?? '') === '' + ) { problems.push({ field: `target.${t.key}`, message: `${t.key} is required.` }); } } for (const s of spec.secrets) { - if (s.required && input.secretRefs[s.param] === undefined) { + if ( + s.required && + (s.mode === undefined || s.mode === mode) && + input.secretRefs[s.param] === undefined + ) { problems.push({ field: `secret_refs.${s.param}`, message: `${s.param} is required (the name of the variable that holds it).`, @@ -276,14 +388,41 @@ export function checkChannelConfig(input: { problems.push({ field: 'target.thread_id', message: 'thread_id must be a number.' }); } } + if (parsed.data === 'discord') { + for (const key of ['channel_id', 'guild_id']) { + const value = t[key]; + if (value !== undefined && value !== '' && !SNOWFLAKE_RE.test(value)) { + problems.push({ field: `target.${key}`, message: `${key} must be a Discord id (digits).` }); + } + } + } if (parsed.data === 'ntfy') { if (t['server'] !== undefined && !HTTP_RE.test(t['server'])) { problems.push({ field: 'target.server', message: 'server must be an absolute http(s) URL.' }); } - if (t['topic'] !== undefined && t['topic'] !== '' && !TOPIC_RE.test(t['topic'])) { + for (const key of ['topic', 'reply_topic']) { + const value = t[key]; + if (value !== undefined && value !== '' && !TOPIC_RE.test(value)) { + problems.push({ + field: `target.${key}`, + message: `${key} may contain letters, digits, _ and - (up to 64).`, + }); + } + } + if (t['reply_topic'] !== undefined && input.secretRefs['reply_topic'] !== undefined) { problems.push({ - field: 'target.topic', - message: 'topic may contain letters, digits, _ and - (up to 64).', + field: 'target.reply_topic', + message: 'reply_topic is given both literally and as a variable; keep one.', + }); + } + if ( + t['reply_topic'] !== undefined && + t['reply_topic'] !== '' && + t['reply_topic'] === t['topic'] + ) { + problems.push({ + field: 'target.reply_topic', + message: 'the reply topic must differ from the topic notifications are sent to.', }); } } @@ -297,6 +436,96 @@ export function checkChannelConfig(input: { return problems; } +/** + * Whether a channel's setup can receive act-button presses (D-41, D-42): Telegram always, Discord in + * bot mode, ntfy with a reply topic (literal or from a variable), the generic webhook (it carries the + * `act` actions without tokens). The rules' `act_buttons` switch decides whether they are used. + * + * @returns True when act buttons can be switched on. + */ +export function supportsActButtons(input: { + readonly kind: string; + readonly mode: string | null; + readonly target: Readonly>; + readonly secretRefs: Readonly>; +}): boolean { + switch (input.kind) { + case 'telegram': + case 'webhook': + return true; + case 'discord': + return (input.mode ?? 'webhook') === 'bot'; + case 'ntfy': + return ( + (input.target['reply_topic'] ?? '') !== '' || input.secretRefs['reply_topic'] !== undefined + ); + default: + return false; + } +} + +/** Whether presses are made by identified platform users (Telegram and Discord), so an allow-list applies. */ +export function hasPresserIdentity(kind: string): boolean { + return kind === 'telegram' || kind === 'discord'; +} + +/** + * Checks the act-button rules of a channel (D-41): the switch only where presses can arrive, and + * an allow-list of numeric platform user ids only where pressers are identified. Shared by the + * API, the startup flag parser and the dashboard. + * + * @returns Every problem (empty when valid). + */ +export function checkChannelRules(input: { + readonly kind: string; + readonly mode: string | null; + readonly target: Readonly>; + readonly secretRefs: Readonly>; + readonly rules: NotificationChannelRules; +}): ChannelConfigProblem[] { + const problems: ChannelConfigProblem[] = []; + if (input.rules.act_buttons === true && !supportsActButtons(input)) { + problems.push({ + field: 'rules.act_buttons', + message: + input.kind === 'discord' + ? 'act buttons need Discord bot mode; webhook messages can only carry links.' + : input.kind === 'ntfy' + ? 'act buttons on ntfy need a reply topic for the buttons to post to.' + : `${input.kind} cannot receive button presses.`, + }); + } + const allow = input.rules.allow_list ?? []; + if (allow.length > 0 && !hasPresserIdentity(input.kind)) { + problems.push({ + field: 'rules.allow_list', + message: `${input.kind} presses carry no user identity, so an allow-list does not apply.`, + }); + } else { + for (const id of allow) { + if (!USER_ID_RE.test(id)) { + problems.push({ + field: 'rules.allow_list', + message: `'${id.slice(0, 24)}' is not a user id (digits only).`, + }); + } + } + } + return problems; +} + +/** What an act-button outcome means, in one sentence (the Actions view, D-41). */ +export const ACTION_OUTCOME_TEXT: Readonly> = { + done: 'The command ran.', + failed: 'The command ran and failed.', + not_allowed: "The person who pressed is not on the channel's allow-list.", + used: 'The button had already been used.', + expired: 'The button had expired (buttons work for 24 hours).', + stale: 'The request was no longer waiting (answered, timed out or cancelled).', + wrong_channel: 'The button was pressed somewhere other than the channel it was sent to.', + disabled: 'Act buttons were off, or the channel was paused, when it was pressed.', +}; + /** Sample notifications the preview renders (spec 03 §4.8.1). */ export const PREVIEW_SAMPLES = [ 'attention', diff --git a/packages/contracts/src/ws/feed-events.ts b/packages/contracts/src/ws/feed-events.ts index f183057..fdf0fc4 100644 --- a/packages/contracts/src/ws/feed-events.ts +++ b/packages/contracts/src/ws/feed-events.ts @@ -4,7 +4,7 @@ import { ClosedReason, DegradationSeverity } from '../enums/index.ts'; import { ScreenshotRow } from '../http/artifacts.ts'; import { OperatorRequestRow } from '../http/attention.ts'; import { BlockedRequestRow } from '../http/blocklist.ts'; -import { ChannelView, DeliveryRow } from '../http/channels.ts'; +import { ActionRow, ChannelView, DeliveryRow } from '../http/channels.ts'; import { Count, EpochMs } from '../http/common.ts'; import { LogRecord } from '../http/logs.ts'; import { Notification } from '../http/notifications.ts'; @@ -155,6 +155,8 @@ export const ChannelRemovedEvent = z.object({ }); /** A delivery job was enqueued or changed status (the live delivery log). */ export const DeliveryUpdatedEvent = z.object({ ...ev('delivery.updated'), delivery: DeliveryRow }); +/** An act-button press was audited (the live Actions view, D-41). */ +export const ActionRecordedEvent = z.object({ ...ev('action.recorded'), action: ActionRow }); // logs ------------------------------------------------------------------------------------------- /** One log record from the ring buffer (droppable under backpressure). */ @@ -190,6 +192,7 @@ export const WsFeedEvent = z.discriminatedUnion('type', [ ChannelChangedEvent, ChannelRemovedEvent, DeliveryUpdatedEvent, + ActionRecordedEvent, LogRecordEvent, ]); /** Every feed event payload. */ @@ -215,7 +218,7 @@ export const WS_TOPIC_EVENTS: { readonly [T in WsStaticTopic]: readonly WsFeedEv ], logs: ['log.record'], notifications: ['notification.created', 'notification.updated'], - channels: ['channel.changed', 'channel.removed', 'delivery.updated'], + channels: ['channel.changed', 'channel.removed', 'delivery.updated', 'action.recorded'], }; /** Event types published on `session:` topics. */ diff --git a/packages/contracts/src/ws/index.ts b/packages/contracts/src/ws/index.ts index e99d87a..442f5cc 100644 --- a/packages/contracts/src/ws/index.ts +++ b/packages/contracts/src/ws/index.ts @@ -31,6 +31,7 @@ export { } from './envelope.ts'; export type { WsFeedEventType } from './feed-events.ts'; export { + ActionRecordedEvent, AttentionCreatedEvent, AttentionResolvedEvent, BlocklistHitEvent, diff --git a/packages/contracts/test/__snapshots__/exports.snapshot.test.ts.snap b/packages/contracts/test/__snapshots__/exports.snapshot.test.ts.snap index 59462c6..833e9c7 100644 --- a/packages/contracts/test/__snapshots__/exports.snapshot.test.ts.snap +++ b/packages/contracts/test/__snapshots__/exports.snapshot.test.ts.snap @@ -14,7 +14,11 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "AVAILABLE_CHANNEL_KINDS", "AVAILABLE_DISCORD_MODES", "ActAction", + "ActionRecordedEvent", + "ActionRow", "ActionStyle", + "ActionsPage", + "ActionsQuery", "ActivityBucket", "ActivityGroupBy", "ActivityQuery", @@ -92,6 +96,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "Channel", "ChannelCapabilitiesDto", "ChannelChangedEvent", + "ChannelConnection", "ChannelEnvQuery", "ChannelEnvResponse", "ChannelId", @@ -137,6 +142,16 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "DeliveryRow", "DeliverySeqParams", "DeliveryUpdatedEvent", + "DiscordBotInfo", + "DiscordBotRequest", + "DiscordChannelsRequest", + "DiscordChannelsResponse", + "DiscordConnectParams", + "DiscordConnectRequest", + "DiscordConnectResponse", + "DiscordConnectStatus", + "DiscordGuild", + "DiscordTextChannel", "DividerBlock", "DomainCount", "DurationMs", @@ -263,6 +278,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "Notification", "NotificationAckResponse", "NotificationAction", + "NotificationActionOutcome", "NotificationCategory", "NotificationChannelKind", "NotificationChannelName", @@ -281,6 +297,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "NotificationId", "NotificationIdParams", "NotificationKind", + "NotificationListenerState", "NotificationMessage", "NotificationPrivacy", "NotificationSeverity", diff --git a/packages/contracts/test/goldens/ws/ws-protocol.json b/packages/contracts/test/goldens/ws/ws-protocol.json index 5b59bd7..1db57f6 100644 --- a/packages/contracts/test/goldens/ws/ws-protocol.json +++ b/packages/contracts/test/goldens/ws/ws-protocol.json @@ -3773,6 +3773,44 @@ "last_status" ], "additionalProperties": false + }, + "connection": { + "anyOf": [ + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "connecting", + "connected", + "reconnecting", + "offline" + ] + }, + "since": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "detail": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "state", + "since", + "detail" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] } }, "required": [ @@ -3796,7 +3834,8 @@ "last_failure_at", "created_at", "updated_at", - "stats" + "stats", + "connection" ], "additionalProperties": false } @@ -4013,6 +4052,113 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "action.recorded" + }, + "action": { + "type": "object", + "properties": { + "seq": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "channel_id": { + "type": "string" + }, + "channel_name": { + "type": "string" + }, + "channel_kind": { + "type": "string" + }, + "notification_id": { + "type": [ + "string", + "null" + ] + }, + "notification_title": { + "type": [ + "string", + "null" + ] + }, + "action_id": { + "type": "string" + }, + "action_label": { + "type": [ + "string", + "null" + ] + }, + "op": { + "type": "string" + }, + "actor": { + "type": "string" + }, + "actor_name": { + "type": [ + "string", + "null" + ] + }, + "outcome": { + "type": "string", + "enum": [ + "done", + "failed", + "not_allowed", + "used", + "expired", + "stale", + "wrong_channel", + "disabled" + ] + }, + "detail": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "seq", + "at", + "channel_id", + "channel_name", + "channel_kind", + "notification_id", + "notification_title", + "action_id", + "action_label", + "op", + "actor", + "actor_name", + "outcome", + "detail" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "action" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -7826,6 +7972,44 @@ "last_status" ], "additionalProperties": false + }, + "connection": { + "anyOf": [ + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "connecting", + "connected", + "reconnecting", + "offline" + ] + }, + "since": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "detail": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "state", + "since", + "detail" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] } }, "required": [ @@ -7849,7 +8033,8 @@ "last_failure_at", "created_at", "updated_at", - "stats" + "stats", + "connection" ], "additionalProperties": false } @@ -8066,6 +8251,113 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "action.recorded" + }, + "action": { + "type": "object", + "properties": { + "seq": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "channel_id": { + "type": "string" + }, + "channel_name": { + "type": "string" + }, + "channel_kind": { + "type": "string" + }, + "notification_id": { + "type": [ + "string", + "null" + ] + }, + "notification_title": { + "type": [ + "string", + "null" + ] + }, + "action_id": { + "type": "string" + }, + "action_label": { + "type": [ + "string", + "null" + ] + }, + "op": { + "type": "string" + }, + "actor": { + "type": "string" + }, + "actor_name": { + "type": [ + "string", + "null" + ] + }, + "outcome": { + "type": "string", + "enum": [ + "done", + "failed", + "not_allowed", + "used", + "expired", + "stale", + "wrong_channel", + "disabled" + ] + }, + "detail": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "seq", + "at", + "channel_id", + "channel_name", + "channel_kind", + "notification_id", + "notification_title", + "action_id", + "action_label", + "op", + "actor", + "actor_name", + "outcome", + "detail" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "action" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/packages/contracts/test/notification-platforms.test.ts b/packages/contracts/test/notification-platforms.test.ts index c114731..739c7f4 100644 --- a/packages/contracts/test/notification-platforms.test.ts +++ b/packages/contracts/test/notification-platforms.test.ts @@ -1,13 +1,20 @@ /** @module contracts/test/notification-platforms.test — the per-platform channel check shared by the API, the startup flag and the dashboard (spec 03 §9.5, D-33), the reason texts and the public-URL grammar */ import { describe, expect, it } from 'bun:test'; import { zPublicUrl } from '../src/config/index.ts'; +import { NotificationActionOutcome } from '../src/enums/index.ts'; import { ChannelInput, ChannelPreviewRequest } from '../src/http/index.ts'; import { + ACTION_OUTCOME_TEXT, + ACTION_PAYLOAD_RE, CHANNEL_KIND_SPECS, checkChannelConfig, + checkChannelRules, + DISCORD_BOT_PERMISSIONS, deliveryReasonText, + discordInviteUrl, looksLikeSecretValue, SUPPRESSION_REASONS, + supportsActButtons, } from '../src/notifications/index.ts'; const check = ( @@ -68,6 +75,79 @@ describe('checkChannelConfig', () => { }); }); +describe('Discord modes and the ntfy reply topic', () => { + const channel = '112233445566778899'; + it("accepts a bot-mode channel and refuses the other mode's keys", () => { + expect(check('discord', { channel_id: channel }, { token: 'BH_BOT' }, 'bot')).toEqual([]); + expect(check('discord', {}, { token: 'BH_BOT' }, 'bot')).toEqual(['target.channel_id']); + expect(check('discord', { channel_id: channel }, {}, 'bot')).toEqual(['secret_refs.token']); + expect(check('discord', { channel_id: channel }, { webhook: 'W', token: 'T' }, 'bot')).toEqual([ + 'secret_refs.webhook', + ]); + expect(check('discord', { channel_id: channel }, { webhook: 'W' }, 'webhook')).toEqual([ + 'target.channel_id', + ]); + expect(check('discord', { channel_id: 'general' }, { token: 'T' }, 'bot')).toEqual([ + 'target.channel_id', + ]); + }); + + it('checks the reply topic', () => { + expect(check('ntfy', { topic: 'a', reply_topic: 'b' }, {})).toEqual([]); + expect( + check('ntfy', { topic: 'a' }, { reply_topic: 'BH_REPLY', reply_token: 'BH_RT' }), + ).toEqual([]); + expect(check('ntfy', { topic: 'a', reply_topic: 'a' }, {})).toEqual(['target.reply_topic']); + expect(check('ntfy', { topic: 'a', reply_topic: 'b' }, { reply_topic: 'B' })).toEqual([ + 'target.reply_topic', + ]); + expect(check('ntfy', { topic: 'a', reply_topic: 'x y' }, {})).toEqual(['target.reply_topic']); + }); + + it('builds the invite link with the minimal permissions', () => { + expect(DISCORD_BOT_PERMISSIONS).toBe((1 << 10) | (1 << 11) | (1 << 14) | (1 << 15)); + expect(discordInviteUrl('42')).toBe( + 'https://discord.com/oauth2/authorize?client_id=42&scope=bot&permissions=52224', + ); + }); +}); + +describe('act-button rules', () => { + const rules = (kind: string, mode: string | null, r: object, target = {}, secretRefs = {}) => + checkChannelRules({ kind, mode, target, secretRefs, rules: r }).map((p) => p.field); + + it('allows act buttons only where presses can arrive', () => { + expect(rules('telegram', null, { act_buttons: true, allow_list: ['123'] })).toEqual([]); + expect(rules('discord', 'bot', { act_buttons: true })).toEqual([]); + expect(rules('discord', 'webhook', { act_buttons: true })).toEqual(['rules.act_buttons']); + expect(rules('ntfy', null, { act_buttons: true })).toEqual(['rules.act_buttons']); + expect(rules('ntfy', null, { act_buttons: true }, { reply_topic: 'b' })).toEqual([]); + expect(rules('ntfy', null, { act_buttons: true }, {}, { reply_topic: 'B' })).toEqual([]); + expect(rules('webhook', null, { act_buttons: true })).toEqual([]); + expect(rules('discord', 'webhook', { act_buttons: false })).toEqual([]); + expect(supportsActButtons({ kind: 'discord', mode: null, target: {}, secretRefs: {} })).toBe( + false, + ); + }); + + it('takes numeric user ids, and no allow-list where presses have no identity', () => { + expect(rules('telegram', null, { allow_list: ['12', 'x1'] })).toEqual(['rules.allow_list']); + expect(rules('ntfy', null, { allow_list: ['12'] }, { reply_topic: 'b' })).toEqual([ + 'rules.allow_list', + ]); + expect(rules('discord', 'bot', { allow_list: ['112233445566778899'] })).toEqual([]); + }); + + it('explains every outcome and recognises button payloads', () => { + for (const outcome of NotificationActionOutcome.options) { + expect(ACTION_OUTCOME_TEXT[outcome]).toBeString(); + } + expect(ACTION_PAYLOAD_RE.exec('bh1:AbC_-12345z')?.[1]).toBe('AbC_-12345z'); + expect(ACTION_PAYLOAD_RE.test('bh1:short')).toBe(false); + expect(ACTION_PAYLOAD_RE.test('bh2:AbC_-12345z')).toBe(false); + }); +}); + describe('deliveryReasonText', () => { it('explains every suppression reason and the dynamic ones', () => { for (const reason of SUPPRESSION_REASONS) { diff --git a/packages/core/src/app/attention/attention-service.test.ts b/packages/core/src/app/attention/attention-service.test.ts index 39bcf0b..19c49f5 100644 --- a/packages/core/src/app/attention/attention-service.test.ts +++ b/packages/core/src/app/attention/attention-service.test.ts @@ -12,7 +12,7 @@ import { CANCELLED_MESSAGE, TIMEOUT_MESSAGE } from '../../domain/operator-reques import type { OperatorRequestEvents } from '../../domain/operator-requests/types.ts'; import type { EventPublisher } from '../../ports/event-bus.ts'; import type { DomainEvents } from '../events/catalog.ts'; -import { AttentionService } from './attention-service.ts'; +import { AttentionService, agentVisibleActor } from './attention-service.ts'; /** Compile-time: the composition root's `EventBus` satisfies the broker's publisher. */ type BusFits = @@ -44,6 +44,15 @@ function harness(transport: 'http' | 'stdio' = 'http') { return { clock, repos, broker, service }; } +describe('agentVisibleActor (D-09, D-41)', () => { + it('keeps a principal and reduces a chat actor to its platform', () => { + expect(agentVisibleActor('admin')).toBe('admin'); + expect(agentVisibleActor('telegram:123456789')).toBe('telegram'); + expect(agentVisibleActor('discord:1')).toBe('discord'); + expect(agentVisibleActor('ntfy:topic-b')).toBe('ntfy'); + }); +}); + describe('AttentionService.request', () => { let h: ReturnType; beforeEach(() => { diff --git a/packages/core/src/app/attention/attention-service.ts b/packages/core/src/app/attention/attention-service.ts index d8f2ad9..cd32379 100644 --- a/packages/core/src/app/attention/attention-service.ts +++ b/packages/core/src/app/attention/attention-service.ts @@ -235,12 +235,21 @@ export class AttentionService { } } +/** + * What the agent learns about who answered: a chat actor (`telegram:`, D-41) is reduced to + * its platform, because the agent is untrusted (D-09) and never learns the operator's chat identity. + */ +export function agentVisibleActor(resolvedBy: string): string { + const match = /^(telegram|discord|ntfy):/.exec(resolvedBy); + return match?.[1] ?? resolvedBy; +} + /** Projects a broker outcome onto the tool shape (`message`/`resolved_by` omitted when absent). */ export function toAttentionOutcome(outcome: OperatorRequestOutcome): AttentionOutcome { return { status: outcome.status, ...(outcome.message !== null && { message: outcome.message }), - ...(outcome.resolvedBy !== null && { resolved_by: outcome.resolvedBy }), + ...(outcome.resolvedBy !== null && { resolved_by: agentVisibleActor(outcome.resolvedBy) }), resolved_at: outcome.resolvedAt, request_id: outcome.requestId, }; diff --git a/packages/core/src/app/config/notification-channel-flag.test.ts b/packages/core/src/app/config/notification-channel-flag.test.ts index 9e5bf19..4bf176a 100644 --- a/packages/core/src/app/config/notification-channel-flag.test.ts +++ b/packages/core/src/app/config/notification-channel-flag.test.ts @@ -8,6 +8,8 @@ const ENV: Record = { BH_NTFY_TOKEN: `tk_${'c'.repeat(29)}`, BH_HOOK_SECRET: 'd'.repeat(32), BH_EMPTY: '', + BH_DISCORD_BOT: 'e'.repeat(40), + BH_NTFY_REPLY: 'bh-replies-x', }; const env = (name: string) => ENV[name]; const parse = (...values: string[]) => parseNotificationChannelFlags(values, env); @@ -132,13 +134,78 @@ describe('parseNotificationChannelFlags', () => { ]); }); - it('refuses duplicate names, repeated parameters and Discord bot mode', () => { + it('never echoes a pasted secret written without its name', () => { + const pasted = `1234:${'z'.repeat(35)}`; + const problems = parse(`telegram:name=a,chat=1,${pasted}`).problems; + expect(problems.join(' ')).not.toContain(pasted); + expect(problems[0]).toContain('not written as name=value'); + }); + + it('refuses duplicate names and repeated parameters', () => { expect(parse('ntfy:name=a,topic=t1', 'ntfy:name=a,topic=t2').problems[0]).toContain( "the name 'a' is used by two channels", ); expect(parse('ntfy:name=a,topic=t1,topic=t2').problems[0]).toContain('given twice'); - expect(parse('discord:name=a,webhook=env:BH_DISCORD_WEBHOOK,mode=bot').problems[0]).toContain( - 'bot mode is not available', + }); + + it('parses act buttons, allow-lists, Discord bot mode and the ntfy reply topic (D-41, D-42)', () => { + const r = parse( + 'telegram:name=phone,token=env:BH_TG_TOKEN,chat=123456,actButtons=true,allow=111+222', + 'discord:name=ops,mode=bot,token=env:BH_DISCORD_BOT,channel=112233445566778899,guild=998877665544332211,actButtons=true,allow=445566778899001122', + 'ntfy:name=pager,topic=bh-alerts-x,reply=env:BH_NTFY_REPLY,replyToken=env:BH_NTFY_TOKEN,actButtons=true', + 'ntfy:name=pager2,topic=bh-alerts-y,reply=bh-replies-y', + ); + expect(r.problems).toEqual([]); + expect(r.channels.map((c) => [c.name, c.mode, c.target, c.secret_refs, c.rules])).toEqual([ + [ + 'phone', + null, + { chat_id: '123456' }, + { token: 'BH_TG_TOKEN' }, + { act_buttons: true, allow_list: ['111', '222'] }, + ], + [ + 'ops', + 'bot', + { channel_id: '112233445566778899', guild_id: '998877665544332211' }, + { token: 'BH_DISCORD_BOT' }, + { act_buttons: true, allow_list: ['445566778899001122'] }, + ], + [ + 'pager', + null, + { topic: 'bh-alerts-x' }, + { reply_topic: 'BH_NTFY_REPLY', reply_token: 'BH_NTFY_TOKEN' }, + { act_buttons: true }, + ], + ['pager2', null, { topic: 'bh-alerts-y', reply_topic: 'bh-replies-y' }, {}, {}], + ]); + }); + + it('refuses act buttons where presses cannot arrive, bad allow-lists and missing bot settings', () => { + expect(parse('discord:name=a,webhook=env:BH_DISCORD_WEBHOOK,actButtons=true').problems).toEqual( + [ + "--notificationChannel 'a': actButtons: act buttons need Discord bot mode; webhook messages can only carry links.", + ], + ); + expect(parse('ntfy:name=a,topic=t1,actButtons=true').problems[0]).toContain( + 'need a reply topic', + ); + expect(parse('ntfy:name=a,topic=t1,reply=t2,allow=1').problems[0]).toContain( + 'allow: ntfy presses carry no user identity', + ); + expect(parse('telegram:name=a,token=env:BH_TG_TOKEN,chat=1,allow=me').problems[0]).toContain( + "allow: 'me' is not a user id", + ); + expect(parse('discord:name=a,webhook=env:BH_DISCORD_WEBHOOK,mode=bot').problems).toEqual([ + "--notificationChannel 'a': channel is required in bot mode.", + "--notificationChannel 'a': token is required in bot mode (token=env:NAME).", + ]); + expect(parse('ntfy:name=a,topic=t1,replyToken=tk_inline').problems[0]).toContain( + 'replyToken must name an environment variable (replyToken=env:NAME)', + ); + expect(parse('telegram:name=a,token=env:BH_TG_TOKEN,chat=1,actButtons=maybe').problems).toEqual( + ["--notificationChannel 'a': actButtons must be true or false."], ); }); diff --git a/packages/core/src/app/config/notification-channel-flag.ts b/packages/core/src/app/config/notification-channel-flag.ts index 912b2cd..89ed089 100644 --- a/packages/core/src/app/config/notification-channel-flag.ts +++ b/packages/core/src/app/config/notification-channel-flag.ts @@ -12,6 +12,7 @@ import { CHANNEL_KIND_SPECS, type ChannelKindSpec, checkChannelConfig, + checkChannelRules, NotificationChannelName, type NotificationChannelRules, NTFY_DEFAULT_SERVER, @@ -46,10 +47,18 @@ const RULE_PARAMS = [ 'deleteWhenResolved', 'images', 'maskImages', + 'actButtons', + 'allow', ] as const; -/** Secret parameters that must be `env:NAME` (topic and url may also be literal). */ -const ALWAYS_SECRET: ReadonlySet = new Set(['token', 'webhook', 'secret', 'password']); +/** Secret parameters that must be `env:NAME` (topics and url may also be literal). */ +const ALWAYS_SECRET: ReadonlySet = new Set([ + 'token', + 'webhook', + 'secret', + 'password', + 'reply_token', +]); const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const QUIET_RE = /^([01]\d|2[0-3]):([0-5]\d)-([01]\d|2[0-3]):([0-5]\d)$/; @@ -62,12 +71,19 @@ function decode(value: string): string | null { } } +/** The flag parameter of a secret (`reply` for `reply_topic`). */ +function flagOf(secret: ChannelKindSpec['secrets'][number]): string { + return secret.flag ?? secret.param; +} + function paramsOf(spec: ChannelKindSpec): readonly string[] { return [ - ...RULE_PARAMS, - ...spec.target.map((t) => t.param), - ...spec.secrets.map((s) => s.param), - ...(spec.modes === null ? [] : ['mode']), + ...new Set([ + ...RULE_PARAMS, + ...spec.target.map((t) => t.param), + ...spec.secrets.map(flagOf), + ...(spec.modes === null ? [] : ['mode']), + ]), ]; } @@ -159,11 +175,15 @@ function parseOne( const value = eq < 0 ? '' : part.slice(eq + 1).trim(); const known = allowed.includes(key) || /^ttl\.[a-z-]+$/.test(key); if (!known) { + // A pasted secret without its `name=` is a "key": never echo anything that is not name-like. + const nameLike = /^[A-Za-z][A-Za-z.-]{0,31}$/.test(key); problems.push( - withSuggestion( - `${nth}: unknown parameter '${key}' for ${spec.label}.`, - suggest(key, allowed), - ), + nameLike + ? withSuggestion( + `${nth}: unknown parameter '${key}' for ${spec.label}.`, + suggest(key, allowed), + ) + : `${nth}: a parameter is not written as name=value (not shown: it may be a secret).`, ); continue; } @@ -193,15 +213,17 @@ function parseOne( } const target: Record = {}; const secretRefs: Record = {}; - // Secrets and the literal-or-variable parameters. - const secretParams = new Set(spec.secrets.map((s) => s.param)); - for (const [key, value] of params) { - if (!secretParams.has(key)) continue; + // Secrets and the literal-or-variable parameters (flag parameter → secret parameter). + const secretByFlag = new Map(spec.secrets.map((s) => [flagOf(s), s.param])); + const secretParams = new Set(secretByFlag.keys()); + for (const [flag, value] of params) { + const key = secretByFlag.get(flag); + if (key === undefined) continue; const fromEnv = value.startsWith('env:'); if (!fromEnv) { if (ALWAYS_SECRET.has(key)) { problems.push( - `${label}: ${key} must name an environment variable (${key}=env:NAME), never contain the secret: other users of this machine can read process arguments.`, + `${label}: ${flag} must name an environment variable (${flag}=env:NAME), never contain the secret: other users of this machine can read process arguments.`, ); continue; } @@ -218,19 +240,19 @@ function parseOne( } const envName = value.slice('env:'.length); if (!ENV_NAME_RE.test(envName)) { - problems.push(`${label}: ${key}=env:NAME needs a variable name ([A-Za-z_][A-Za-z0-9_]*).`); + problems.push(`${label}: ${flag}=env:NAME needs a variable name ([A-Za-z_][A-Za-z0-9_]*).`); continue; } if (envName.startsWith(RESERVED_ENV_PREFIX)) { problems.push( - `${label}: ${key}: variables starting with ${RESERVED_ENV_PREFIX} are reserved for configuration; use another name.`, + `${label}: ${flag}: variables starting with ${RESERVED_ENV_PREFIX} are reserved for configuration; use another name.`, ); continue; } const current = env(envName); if (current === undefined || current === '') { problems.push( - `${label}: ${envName} is not set (${key}=env:${envName}). Set it in the environment that starts BrowserHive.`, + `${label}: ${envName} is not set (${flag}=env:${envName}). Set it in the environment that starts BrowserHive.`, ); continue; } @@ -242,27 +264,32 @@ function parseOne( } let mode: string | null = spec.defaultMode; const modeParam = params.get('mode'); - if (modeParam !== undefined) { - if (modeParam === 'bot') { - problems.push( - `${label}: Discord bot mode is not available in this release; use mode=webhook (the default).`, - ); - } else mode = modeParam; - } + if (modeParam !== undefined) mode = modeParam; + const inMode = (owner: string | undefined) => owner === undefined || owner === mode; const rules = parseRules(params, label, kind.data, problems); for (const t of spec.target) { - if (t.required && !params.has(t.param)) problems.push(`${label}: ${t.param} is required.`); + if (t.required && inMode(t.mode) && !params.has(t.param)) { + problems.push( + `${label}: ${t.param} is required${t.mode === undefined ? '' : ` in ${t.mode} mode`}.`, + ); + } } for (const secret of spec.secrets) { - if (secret.required && !params.has(secret.param)) { - problems.push(`${label}: ${secret.param} is required (${secret.param}=env:NAME).`); + const flag = flagOf(secret); + if (secret.required && inMode(secret.mode) && !params.has(flag)) { + problems.push( + `${label}: ${flag} is required${secret.mode === undefined ? '' : ` in ${secret.mode} mode`} (${flag}=env:NAME).`, + ); } } for (const key of spec.eitherTargetOrSecret) { if (!params.has(key)) problems.push(`${label}: ${key} is required.`); } if (problems.length === 0) { - for (const problem of checkChannelConfig({ kind: kind.data, mode, target, secretRefs })) { + for (const problem of [ + ...checkChannelConfig({ kind: kind.data, mode, target, secretRefs }), + ...checkChannelRules({ kind: kind.data, mode, target, secretRefs, rules }), + ]) { problems.push(`${label}: ${paramForField(spec, problem.field)}: ${problem.message}`); } } @@ -282,7 +309,11 @@ function parseOne( } function paramForField(spec: ChannelKindSpec, field: string): string { + if (field === 'rules.act_buttons') return 'actButtons'; + if (field === 'rules.allow_list') return 'allow'; const key = field.replace(/^(target|secret_refs)\./, ''); + const secret = spec.secrets.find((s) => s.param === key); + if (field.startsWith('secret_refs.') && secret !== undefined) return flagOf(secret); return spec.target.find((t) => t.key === key)?.param ?? key; } @@ -418,5 +449,13 @@ function parseRules( if (flag === null) problems.push(`${label}: maskImages must be true or false.`); else rules.mask_images = flag; } + const act = params.get('actButtons'); + if (act !== undefined) { + const flag = parseBool(act); + if (flag === null) problems.push(`${label}: actButtons must be true or false.`); + else rules.act_buttons = flag; + } + const allow = params.get('allow'); + if (allow !== undefined) rules.allow_list = list(allow); return rules; } diff --git a/packages/core/src/app/events/catalog.ts b/packages/core/src/app/events/catalog.ts index c5cf8e5..a074676 100644 --- a/packages/core/src/app/events/catalog.ts +++ b/packages/core/src/app/events/catalog.ts @@ -2,6 +2,7 @@ import type { ToolName } from '@browserhive/contracts/tools'; import type { + ActionRecordedEvent, AttentionCreatedEvent, AttentionResolvedEvent, BlocklistHitEvent, @@ -165,6 +166,7 @@ export type DomainEvents = { readonly 'channel.changed': z.infer; readonly 'channel.removed': z.infer; readonly 'delivery.updated': z.infer; + readonly 'action.recorded': z.infer; // logs readonly 'log.record': z.infer; } & AuthEvents; // auth (audit; never on the public feed): `auth.` diff --git a/packages/core/src/app/notifications/action-listeners.test.ts b/packages/core/src/app/notifications/action-listeners.test.ts new file mode 100644 index 0000000..6ae408b --- /dev/null +++ b/packages/core/src/app/notifications/action-listeners.test.ts @@ -0,0 +1,182 @@ +/** @module app/notifications/action-listeners.test — one press listener per channel with act buttons (spec 03 §9.6): started with the registry, replaced when the adapter's source changes, stopped when act buttons go off or the channel is removed; state changes re-publish the channel. Also the outbox's minting seam: tokens only for channels that receive presses. */ + +import { describe, expect, it } from 'bun:test'; +import { CollectingLogger } from '../../../test/helpers/collecting-logger.ts'; +import { capabilities, channelRecord, FakeChannel } from '../../../test/helpers/fake-channel.ts'; +import { FakeClock } from '../../../test/helpers/fake-clock.ts'; +import { FakeIdGenerator } from '../../../test/helpers/fake-id-generator.ts'; +import { InMemoryRepositories, InMemoryUnitOfWork } from '../../../test/helpers/in-memory-repos.ts'; +import type { + ListenerStatus, + PressHandler, + PressSource, +} from '../../ports/notification-channel.ts'; +import type { NotificationChannelRecord } from '../../ports/persistence/records.ts'; +import { NotificationActionListeners } from './action-listeners.ts'; +import { ChannelRegistry } from './channel-registry.ts'; +import { createLocalLinkBuilder } from './links.ts'; +import { NotificationService } from './notification-service.ts'; +import { NotificationOutbox } from './outbox.ts'; +import { attentionCreated } from './test-fixtures.ts'; + +class FakeSource implements PressSource { + readonly handlers: PressHandler[] = []; + stops = 0; + private notify: ((s: ListenerStatus) => void) | null = null; + current: ListenerStatus = { state: 'connecting', since: 0, detail: null }; + + listen(handler: PressHandler, onStatus: (s: ListenerStatus) => void): () => void { + this.handlers.push(handler); + this.notify = onStatus; + return () => { + this.stops += 1; + }; + } + + status(): ListenerStatus { + return this.current; + } + + set(state: ListenerStatus['state']): void { + this.current = { state, since: 1, detail: null }; + this.notify?.(this.current); + } +} + +async function setup(record: NotificationChannelRecord) { + const repos = new InMemoryRepositories(); + const clock = new FakeClock(); + await repos.notificationChannels.upsert(record); + const sources: FakeSource[] = []; + const registry = new ChannelRegistry({ + repo: repos.notificationChannels, + clock, + ids: new FakeIdGenerator(), + logger: new CollectingLogger(), + factories: new Map([ + [ + 'fake', + (row) => { + const fake = new FakeChannel(row.channelId, capabilities({ actButtons: true })); + if (row.rules.act_buttons !== true) return fake; + const source = new FakeSource(); + sources.push(source); + return Object.assign(fake, { presses: source }); + }, + ], + ]), + }); + await registry.load(); + const changed: string[] = []; + const listeners = new NotificationActionListeners({ + registry, + handler: async () => ({ outcome: 'done', text: 'ok', refused: false }), + logger: new CollectingLogger(), + onStatus: (id) => changed.push(id), + }); + return { repos, clock, registry, listeners, sources, changed }; +} + +describe('NotificationActionListeners', () => { + it('listens to channels with act buttons and follows registry reloads', async () => { + const t = await setup(channelRecord({ rules: { act_buttons: true } })); + t.listeners.start(); + expect(t.sources).toHaveLength(1); + expect(t.sources[0]?.handlers).toHaveLength(1); + expect(t.listeners.status('nc-000000000001')?.state).toBe('connecting'); + t.sources[0]?.set('connected'); + expect(t.listeners.status('nc-000000000001')?.state).toBe('connected'); + expect(t.changed.length).toBeGreaterThanOrEqual(2); + // A reload rebuilds the adapter: the old listener stops, the new one starts. + await t.registry.reload(); + expect(t.sources[0]?.stops).toBe(1); + expect(t.sources[1]?.handlers).toHaveLength(1); + // Act buttons off: no listener. + await t.repos.notificationChannels.upsert(channelRecord({ rules: {} })); + await t.registry.reload(); + expect(t.sources[1]?.stops).toBe(1); + expect(t.listeners.status('nc-000000000001')).toBeNull(); + t.listeners.stop(); + }); + + it('never listens without act buttons, and stops everything on stop', async () => { + const off = await setup(channelRecord()); + off.listeners.start(); + expect(off.sources).toHaveLength(0); + const on = await setup(channelRecord({ rules: { act_buttons: true } })); + on.listeners.start(); + on.listeners.stop(); + expect(on.sources[0]?.stops).toBe(1); + on.sources[0]?.set('connected'); + expect(on.listeners.status('nc-000000000001')).toBeNull(); + }); +}); + +describe('outbox minting', () => { + async function deliver(rules: NotificationChannelRecord['rules'], withPresses: boolean) { + const repos = new InMemoryRepositories(); + const uow = new InMemoryUnitOfWork(repos); + const clock = new FakeClock(); + const ids = new FakeIdGenerator(); + const logger = new CollectingLogger(); + await repos.notificationChannels.upsert(channelRecord({ rules })); + const fake = new FakeChannel('nc-000000000001', capabilities({ actButtons: true })); + const adapter = withPresses ? Object.assign(fake, { presses: new FakeSource() }) : fake; + const registry = new ChannelRegistry({ + repo: repos.notificationChannels, + clock, + ids, + logger, + factories: new Map([['fake', () => adapter]]), + }); + await registry.load(); + const minted: string[] = []; + const outbox = new NotificationOutbox({ + uow, + repos, + registry, + links: createLocalLinkBuilder(() => 'http://127.0.0.1:9876'), + clock, + logger, + actions: { + async mint(channelId, message) { + minted.push(channelId); + return new Map( + message.actions.filter((a) => a.kind === 'act').map((a) => [a.id, `bh1:${a.id}`]), + ); + }, + }, + }); + const service = new NotificationService({ + repo: repos.notifications, + bus: { + publish: () => undefined, + subscribe: () => () => undefined, + subscribeAll: () => () => undefined, + }, + clock, + ids, + logger, + uow, + outbox: { plan: (m, now) => outbox.plan(m, now), kick: () => undefined }, + }); + await service.produce(attentionCreated('a-000000000001', 'takeover')); + await outbox.tick(); + return { minted, delivery: fake.ops('send')[0]?.delivery ?? null }; + } + + it('mints tokens before the call for a channel that receives presses', async () => { + const { minted, delivery } = await deliver({ act_buttons: true }, true); + expect(minted).toEqual(['nc-000000000001']); + expect([...(delivery?.actTokens?.entries() ?? [])]).toEqual([ + ['resolve', 'bh1:resolve'], + ['reject', 'bh1:reject'], + ]); + }); + + it('mints nothing for a channel without a press listener', async () => { + const { minted, delivery } = await deliver({}, false); + expect(minted).toEqual([]); + expect(delivery?.actTokens).toBeUndefined(); + }); +}); diff --git a/packages/core/src/app/notifications/action-listeners.ts b/packages/core/src/app/notifications/action-listeners.ts new file mode 100644 index 0000000..a0e717f --- /dev/null +++ b/packages/core/src/app/notifications/action-listeners.ts @@ -0,0 +1,110 @@ +/** @module app/notifications/action-listeners — keeps one press listener per channel with act buttons (spec 03 §9.6, D-41): follows the registry, hands presses to the action service, and tracks each listener's state for the channel cards. */ + +import { serializeError } from '../../kernel/errors/serialize-error.ts'; +import type { Logger } from '../../ports/logger.ts'; +import type { + ListenerStatus, + PressHandler, + PressSource, +} from '../../ports/notification-channel.ts'; +import type { ChannelRegistry } from './channel-registry.ts'; + +/** Dependencies of {@link NotificationActionListeners}. */ +export interface NotificationActionListenersDeps { + readonly registry: ChannelRegistry; + /** Handles every press (the action service's `press`). */ + readonly handler: PressHandler; + readonly logger: Logger; + /** Called after a channel's listener changed state (re-publishes the channel). Must not throw. */ + readonly onStatus?: (channelId: string) => void; +} + +interface Running { + readonly source: PressSource; + readonly stop: () => void; + status: ListenerStatus; +} + +/** + * The press listeners of every channel whose adapter receives presses (an adapter declares + * `presses` only while its channel's act buttons are on). A registry reload rebuilds adapters; a + * listener is replaced only when its adapter's source changed, and the shared connections behind + * the sources outlive a quick stop-and-start. + */ +export class NotificationActionListeners { + private readonly running = new Map(); + private readonly log: Logger; + private offRegistry: (() => void) | undefined; + + constructor(private readonly deps: NotificationActionListenersDeps) { + this.log = deps.logger.child({ module: 'notifications' }); + } + + /** Starts listening for the current channels and follows registry reloads. Idempotent. */ + start(): void { + if (this.offRegistry !== undefined) return; + this.offRegistry = this.deps.registry.onChange(() => this.sync()); + this.sync(); + } + + /** Stops every listener. Idempotent. */ + stop(): void { + this.offRegistry?.(); + this.offRegistry = undefined; + for (const [channelId, run] of this.running) this.halt(channelId, run); + this.running.clear(); + } + + /** The state of a channel's listener, or `null` when it has none. */ + status(channelId: string): ListenerStatus | null { + return this.running.get(channelId)?.status ?? null; + } + + private sync(): void { + const wanted = new Map(); + for (const entry of this.deps.registry.channels()) { + const source = entry.adapter?.presses; + if (source !== undefined && entry.record.rules.act_buttons === true) { + wanted.set(entry.record.channelId, source); + } + } + for (const [channelId, run] of this.running) { + if (wanted.get(channelId) !== run.source) { + this.halt(channelId, run); + this.running.delete(channelId); + this.changed(channelId); + } + } + for (const [channelId, source] of wanted) { + if (this.running.has(channelId)) continue; + try { + const stop = source.listen(this.deps.handler, (status) => { + const current = this.running.get(channelId); + if (current === undefined || current.source !== source) return; + current.status = status; + this.changed(channelId); + }); + this.running.set(channelId, { source, stop, status: source.status() }); + this.changed(channelId); + } catch (err) { + this.log.warn('press listener failed', { channel_id: channelId, err: serializeError(err) }); + } + } + } + + private halt(channelId: string, run: Running): void { + try { + run.stop(); + } catch (err) { + this.log.warn('press listener failed', { channel_id: channelId, err: serializeError(err) }); + } + } + + private changed(channelId: string): void { + try { + this.deps.onStatus?.(channelId); + } catch (err) { + this.log.warn('press listener failed', { channel_id: channelId, err: serializeError(err) }); + } + } +} diff --git a/packages/core/src/app/notifications/actions.test.ts b/packages/core/src/app/notifications/actions.test.ts new file mode 100644 index 0000000..8fb4f8d --- /dev/null +++ b/packages/core/src/app/notifications/actions.test.ts @@ -0,0 +1,331 @@ +/** @module app/notifications/actions.test — act buttons (spec 03 §9.6, D-41): minting stores only hashes before the call; a press is checked (unknown, wrong channel or chat, disabled, used, expired, stale, not on the allow-list) and run through the executor with the chat actor, audited once, published, and answered; concurrent presses run once; the audit pages newest first. */ + +import { describe, expect, it } from 'bun:test'; +import { ACTION_TOKEN_TTL_MS } from '@browserhive/contracts/notifications'; +import { CollectingLogger } from '../../../test/helpers/collecting-logger.ts'; +import { capabilities, channelRecord, FakeChannel } from '../../../test/helpers/fake-channel.ts'; +import { FakeClock } from '../../../test/helpers/fake-clock.ts'; +import { FakeIdGenerator } from '../../../test/helpers/fake-id-generator.ts'; +import { InMemoryRepositories, InMemoryUnitOfWork } from '../../../test/helpers/in-memory-repos.ts'; +import { RecordingEventBus } from '../../../test/helpers/recording-event-bus.ts'; +import { sha256Hex } from '../../domain/auth/digest.ts'; +import { AppError } from '../../kernel/errors/app-error.ts'; +import type { PressEvent } from '../../ports/notification-channel.ts'; +import type { NotificationChannelRecord } from '../../ports/persistence/records.ts'; +import type { DomainEvents } from '../events/catalog.ts'; +import { type ActionExecutor, actorOf, NotificationActionService } from './actions.ts'; +import { ChannelRegistry } from './channel-registry.ts'; +import { decodeMessage } from './message.ts'; +import { NotificationService } from './notification-service.ts'; +import { attentionCreated, vaultConfirmCreated } from './test-fixtures.ts'; + +const CHAT = '-1001'; + +interface Options { + readonly channel?: Partial; + readonly executor?: ActionExecutor['run']; +} + +async function setup(opts: Options = {}) { + const clock = new FakeClock(); + const repos = new InMemoryRepositories(); + const uow = new InMemoryUnitOfWork(repos); + const bus = new RecordingEventBus(); + const logger = new CollectingLogger(); + const ids = new FakeIdGenerator(); + const record = channelRecord({ + kind: 'telegram', + target: { chat_id: CHAT }, + rules: { act_buttons: true, allow_list: ['42'] }, + ...opts.channel, + }); + await repos.notificationChannels.upsert(record); + const fake = new FakeChannel( + record.channelId, + capabilities({ actButtons: true }), + 'phone', + 'telegram', + ); + const registry = new ChannelRegistry({ + repo: repos.notificationChannels, + clock, + ids, + logger, + factories: new Map([['telegram', () => fake]]), + }); + await registry.load(); + const runs: { args: unknown; actor: string }[] = []; + const executors = new Map([ + [ + 'attention.resolve', + { + scope: 'attention:resolve', + run: + opts.executor ?? + (async (args, actor) => { + runs.push({ args, actor }); + return args['decision'] === 'reject' ? 'Rejected.' : 'Marked resolved.'; + }), + }, + ], + ]); + const counted: { channel_kind: string; outcome: string }[] = []; + const actions = new NotificationActionService({ + repos, + registry, + clock, + ids, + logger, + executors, + bus, + counter: { add: (_n, a) => void counted.push({ ...a }) }, + }); + const notifications = new NotificationService({ + repo: repos.notifications, + bus, + clock, + ids, + logger, + uow, + }); + await notifications.produce(attentionCreated('a-000000000001', 'takeover')); + const row = [...repos.notifications.rows.values()][0]; + const message = decodeMessage(row?.messageJson ?? null); + if (row === undefined || message === null) throw new Error('no notification'); + const tokens = await actions.mint(record.channelId, message, clock.now()); + const token = (actionId: string) => (tokens.get(actionId) ?? '').slice('bh1:'.length); + const press = (actionId: string, overrides: Partial = {}) => + actions.press({ + token: token(actionId), + origin: CHAT, + actor: { platform: 'telegram', id: '42', name: 'Amir' }, + ...overrides, + }); + return { + logger, + clock, + repos, + bus, + registry, + actions, + notifications, + row, + message, + tokens, + token, + press, + runs, + counted, + }; +} + +describe('mint', () => { + it('writes one hashed token per act action before the call, bound to the command', async () => { + const t = await setup(); + expect([...t.tokens.keys()]).toEqual(['resolve', 'reject']); + for (const payload of t.tokens.values()) expect(payload).toMatch(/^bh1:[A-Za-z0-9_-]{11}$/); + const rows = [...t.repos.notificationActionTokens.rows.values()]; + expect(rows.map((r) => [r.actionId, r.op, r.args['decision']])).toEqual([ + ['resolve', 'attention.resolve', 'resolve'], + ['reject', 'attention.resolve', 'reject'], + ]); + // Only the hash is stored. + expect(rows[0]?.tokenHash).toBe(sha256Hex(t.token('resolve'))); + expect(JSON.stringify(rows)).not.toContain(t.token('resolve')); + expect(rows[0]?.expiresAt).toBe((rows[0]?.createdAt ?? 0) + ACTION_TOKEN_TTL_MS); + }); + + it('mints fresh tokens on every call (only hashes exist to re-use)', async () => { + const t = await setup(); + const again = await t.actions.mint('nc-000000000001', t.message, t.clock.now()); + expect(again.get('resolve')).not.toBe(t.tokens.get('resolve')); + expect(t.repos.notificationActionTokens.rows.size).toBe(4); + }); +}); + +describe('press', () => { + it('runs the command as the chat actor, audits once, publishes and answers', async () => { + const t = await setup(); + const answer = await t.press('reject'); + expect(answer).toEqual({ outcome: 'done', text: 'Rejected.', refused: false }); + expect(t.runs).toEqual([ + { args: { request_id: 'a-000000000001', decision: 'reject' }, actor: 'telegram:42' }, + ]); + const [audit] = t.repos.notificationActions.rows; + expect(audit).toMatchObject({ + channelName: 'phone', + channelKind: 'telegram', + notificationId: t.row.notificationId, + actionId: 'reject', + actionLabel: 'Reject', + op: 'attention.resolve', + actor: 'telegram:42', + actorName: 'Amir', + outcome: 'done', + }); + const published = t.bus.published.filter((e) => e.name === 'action.recorded'); + expect(published).toHaveLength(1); + expect(JSON.stringify(published)).not.toContain(t.token('reject')); + expect(t.counted).toEqual([{ channel_kind: 'telegram', outcome: 'done' }]); + }); + + it('is single use, also for two presses at once', async () => { + const t = await setup(); + const [a, b] = await Promise.all([t.press('resolve'), t.press('resolve')]); + expect([a.outcome, b.outcome].sort()).toEqual(['done', 'used']); + expect(t.runs).toHaveLength(1); + expect((await t.press('resolve')).outcome).toBe('used'); + }); + + it('answers an unknown token without auditing it', async () => { + const t = await setup(); + const answer = await t.actions.press({ + token: 'zzzzzzzzzzz', + origin: CHAT, + actor: { platform: 'telegram', id: '42', name: null }, + }); + expect(answer).toEqual({ + outcome: 'unknown', + text: 'This button is no longer valid.', + refused: true, + }); + expect(t.repos.notificationActions.rows).toHaveLength(0); + expect(t.counted).toEqual([{ channel_kind: 'telegram', outcome: 'unknown' }]); + }); + + it('refuses a press from another chat or platform', async () => { + const t = await setup(); + expect((await t.press('resolve', { origin: '-999' })).outcome).toBe('wrong_channel'); + expect( + (await t.press('resolve', { actor: { platform: 'discord', id: '42', name: null } })).outcome, + ).toBe('wrong_channel'); + expect(t.runs).toHaveLength(0); + // The refusals did not use the token. + expect((await t.press('resolve')).outcome).toBe('done'); + }); + + it('refuses a presser who is not on the allow-list, naming their id', async () => { + const t = await setup(); + const answer = await t.press('resolve', { + actor: { platform: 'telegram', id: '7', name: 'Stranger' }, + }); + expect(answer.outcome).toBe('not_allowed'); + expect(answer.text).toBe( + 'You are not allowed to answer here yet. Ask the BrowserHive admin to add you (Notifications → Channels → "phone" → Answer from the chat). Your Telegram id is 7.', + ); + expect(t.repos.notificationActions.rows[0]).toMatchObject({ + actor: 'telegram:7', + outcome: 'not_allowed', + }); + const empty = await setup({ channel: { rules: { act_buttons: true } } }); + expect((await empty.press('resolve')).outcome).toBe('not_allowed'); + }); + + it('refuses while act buttons are off or the channel is paused', async () => { + const off = await setup({ channel: { rules: { allow_list: ['42'] } } }); + expect((await off.press('resolve')).outcome).toBe('disabled'); + const paused = await setup({ channel: { status: 'paused' } }); + expect((await paused.press('resolve')).outcome).toBe('disabled'); + }); + + it('refuses an expired token and a notification that is no longer open', async () => { + const t = await setup(); + await t.clock.advance(ACTION_TOKEN_TTL_MS + 1); + expect((await t.press('resolve')).outcome).toBe('expired'); + const s = await setup(); + await s.repos.notifications.revise(s.row.notificationId, { + state: 'resolved', + severity: 'warn', + revision: 2, + messageJson: s.row.messageJson, + }); + const answer = await s.press('resolve'); + expect(answer).toMatchObject({ outcome: 'stale', text: 'This request is no longer waiting.' }); + expect(s.runs).toHaveLength(0); + }); + + it('reports a request settled meanwhile as stale and any other failure as failed', async () => { + const stale = await setup({ + executor: async () => { + throw new AppError('ATTENTION_NOT_OPEN', { request_id: 'a', status: 'resolved' }); + }, + }); + expect((await stale.press('resolve')).outcome).toBe('stale'); + const broken = await setup({ + executor: async () => { + throw new Error('database is locked'); + }, + }); + const answer = await broken.press('resolve'); + expect(answer.outcome).toBe('failed'); + expect(answer.text).toContain('database is locked'); + expect(broken.repos.notificationActions.rows[0]?.outcome).toBe('failed'); + }); + + it('refuses an op no producer offers (no executor)', async () => { + const t = await setup(); + await t.notifications.produce(vaultConfirmCreated('a-000000000009', 'github')); + const row = [...t.repos.notifications.rows.values()].find((r) => r.kind === 'vault.confirm'); + const message = decodeMessage(row?.messageJson ?? null); + if (message === null) throw new Error('no vault message'); + const tokens = await t.actions.mint('nc-000000000001', message, t.clock.now()); + const answer = await t.actions.press({ + token: (tokens.get('approve') ?? '').slice(4), + origin: CHAT, + actor: { platform: 'telegram', id: '42', name: null }, + }); + expect(answer.outcome).toBe('failed'); + expect(answer.text).toContain('cannot be answered from a chat'); + }); +}); + +describe('secrets', () => { + it('never writes a token to a log line, the audit, a bus event or a delivery', async () => { + const t = await setup(); + await t.press('resolve', { actor: { platform: 'telegram', id: '7', name: null } }); + await t.press('resolve'); + await t.press('resolve'); + await t.actions.press({ + token: 'zzzzzzzzzzz', + origin: CHAT, + actor: { platform: 'telegram', id: '42', name: null }, + }); + const sinks = JSON.stringify([ + t.logger.records, + t.repos.notificationActions.rows, + t.bus.published, + t.repos.notificationDeliveries.rows, + [...t.repos.notifications.rows.values()], + ]); + for (const payload of t.tokens.values()) { + expect(sinks).not.toContain(payload.slice(4)); + } + expect(sinks).not.toContain('zzzzzzzzzzz'); + }); +}); + +describe('list', () => { + it('pages the audit newest first with the notification title', async () => { + const t = await setup(); + await t.press('resolve', { actor: { platform: 'telegram', id: '7', name: null } }); + await t.press('resolve', { origin: '-5' }); + await t.press('resolve'); + const first = await t.actions.list({ limit: 2 }); + expect(first.items.map((a) => a.outcome)).toEqual(['done', 'wrong_channel']); + expect(first.items[0]?.notification_title).toBe('Attention requested'); + expect(first.nextCursor).not.toBeNull(); + const second = await t.actions.list({ limit: 2, cursor: first.nextCursor ?? '' }); + expect(second.items.map((a) => a.outcome)).toEqual(['not_allowed']); + expect(second.nextCursor).toBeNull(); + const filtered = await t.actions.list({ limit: 10, outcomes: ['not_allowed'] }); + expect(filtered.items).toHaveLength(1); + }); +}); + +describe('actorOf', () => { + it('names the platform and the user, or the ntfy reply topic', () => { + expect(actorOf({ platform: 'telegram', id: '42', name: null })).toBe('telegram:42'); + expect(actorOf({ platform: 'discord', id: '9', name: 'x' })).toBe('discord:9'); + expect(actorOf({ platform: 'ntfy', id: null, name: null })).toBe('ntfy:topic-b'); + }); +}); diff --git a/packages/core/src/app/notifications/actions.ts b/packages/core/src/app/notifications/actions.ts new file mode 100644 index 0000000..afed6e8 --- /dev/null +++ b/packages/core/src/app/notifications/actions.ts @@ -0,0 +1,401 @@ +/** @module app/notifications/actions — act buttons (spec 03 §9.6, D-41): mints single-use command tokens for the outbox, checks and runs a press through the same services as the dashboard, audits it, and lists the audit. */ + +import type { ActionRow } from '@browserhive/contracts/http'; +import { + ACTION_OUTCOME_TEXT, + ACTION_TOKEN_LENGTH, + ACTION_TOKEN_PREFIX, + ACTION_TOKEN_TTL_MS, + hasPresserIdentity, + type NotificationMessage, +} from '@browserhive/contracts/notifications'; +import { SpanStatusCode, type Tracer, trace } from '@opentelemetry/api'; +import { sha256Hex } from '../../domain/auth/digest.ts'; +import { AppError, isAppError } from '../../kernel/errors/app-error.ts'; +import { serializeError } from '../../kernel/errors/serialize-error.ts'; +import type { Redactor } from '../../kernel/redact.ts'; +import type { Clock } from '../../ports/clock.ts'; +import type { EventPublisher } from '../../ports/event-bus.ts'; +import type { IdGenerator } from '../../ports/id-generator.ts'; +import type { Logger } from '../../ports/logger.ts'; +import type { PressAnswer, PressEvent } from '../../ports/notification-channel.ts'; +import type { NotificationActionOutcome } from '../../ports/persistence/enums.ts'; +import type { + NotificationActionRecord, + NotificationActionTokenRecord, + NotificationChannelRecord, +} from '../../ports/persistence/records.ts'; +import type { Repositories } from '../../ports/persistence/unit-of-work.ts'; +import type { DomainEvents } from '../events/catalog.ts'; +import type { ChannelRegistry } from './channel-registry.ts'; +import { clip, decodeMessage } from './message.ts'; + +/** Longest detail stored on an audit row. */ +const DETAIL_MAX = 500; + +/** Runs one command op for a press. Returns the short success text ("Resolved"). */ +export interface ActionExecutor { + /** The scope of the dashboard route this op mirrors (recorded on the span). */ + readonly scope: string; + run(args: Readonly>, actor: string): Promise; +} + +/** Counter of presses (spec 10 §7). */ +export interface ActionCounter { + add(value: number, attributes: { readonly channel_kind: string; readonly outcome: string }): void; +} + +/** Dependencies of {@link NotificationActionService}. */ +export interface NotificationActionServiceDeps { + readonly repos: Pick< + Repositories, + 'notifications' | 'notificationActionTokens' | 'notificationActions' + >; + readonly registry: ChannelRegistry; + readonly clock: Clock; + readonly ids: IdGenerator; + readonly logger: Logger; + /** The command ops a press may run, by `NotificationCommandOp`. */ + readonly executors: ReadonlyMap; + /** Receives `action.recorded` (the live Actions view). */ + readonly bus?: EventPublisher; + readonly redactor?: Redactor; + readonly counter?: ActionCounter; + readonly tracer?: Tracer; +} + +/** A page of the press audit. */ +export interface ActionPage { + readonly items: readonly ActionRow[]; + readonly nextCursor: string | null; +} + +/** Filters of the press audit. */ +export interface ActionListInput { + readonly cursor?: string; + readonly limit: number; + readonly channelId?: string; + readonly notificationId?: string; + readonly outcomes?: readonly NotificationActionOutcome[]; +} + +/** The actor a press is recorded as (`telegram:`, `discord:`, `ntfy:topic-b`). */ +export function actorOf(actor: PressEvent['actor']): string { + if (actor.platform === 'ntfy' || actor.id === null) return `${actor.platform}:topic-b`; + return `${actor.platform}:${actor.id}`; +} + +const PLATFORM_LABEL: Readonly> = { + telegram: 'Telegram', + discord: 'Discord', + ntfy: 'ntfy', +}; + +/** + * The answer text of each refusal. Only the presser sees it (a Telegram callback answer, an + * ephemeral Discord reply), so a refused presser may read their own id, to ask for access. + */ +function refusalText( + outcome: Exclude, + press: PressEvent, + channelName: string | null, +): string { + switch (outcome) { + case 'not_allowed': { + const where = channelName === null ? 'the channel' : `"${channelName}"`; + const id = + press.actor.id === null + ? '' + : ` Your ${PLATFORM_LABEL[press.actor.platform] ?? press.actor.platform} id is ${press.actor.id}.`; + return `You are not allowed to answer here yet. Ask the BrowserHive admin to add you (Notifications → Channels → ${where} → Answer from the chat).${id}`; + } + case 'used': + return 'This button was already used.'; + case 'expired': + return 'This button has expired.'; + case 'stale': + return 'This request is no longer waiting.'; + case 'wrong_channel': + return 'This button belongs to another chat.'; + case 'disabled': + return 'Answering from the chat is switched off for this channel.'; + case 'failed': + return 'That did not work; open BrowserHive to answer.'; + } +} + +function encodeCursor(seq: number): string { + return Buffer.from(JSON.stringify({ seq })).toString('base64url'); +} + +function decodeCursor(cursor: string): number { + try { + const parsed: unknown = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + const seq = + typeof parsed === 'object' && parsed !== null ? (parsed as { seq?: unknown }).seq : null; + if (typeof seq === 'number' && Number.isInteger(seq) && seq > 0) return seq; + } catch { + // fall through + } + throw new AppError('VALIDATION_FAILED', { + issues: [{ path: 'cursor', message: 'not an action cursor', code: 'custom' }], + }); +} + +/** Where a channel's presses must come from: its Telegram chat or Discord channel (`null`: anywhere). */ +export function pressOrigin(record: NotificationChannelRecord): string | null { + if (record.kind === 'telegram') return record.target['chat_id'] ?? null; + if (record.kind === 'discord') return record.target['channel_id'] ?? null; + return null; +} + +/** + * Act buttons (D-41): the outbox mints tokens through {@link mint}; the press listeners hand every + * press to {@link press}, which never throws. + */ +export class NotificationActionService { + private readonly log: Logger; + private readonly tracer: Tracer; + + constructor(private readonly deps: NotificationActionServiceDeps) { + this.log = deps.logger.child({ module: 'notifications' }); + this.tracer = deps.tracer ?? trace.getTracer('browserhive'); + } + + /** + * Mints one fresh token per act action of `message` for `channelId` and stores their hashes + * before the platform call (an early press then finds its row). + * + * @returns Action id → button payload (`bh1:`). + */ + async mint( + channelId: string, + message: NotificationMessage, + now: number, + ): Promise> { + const payloads = new Map(); + const rows: NotificationActionTokenRecord[] = []; + for (const action of message.actions) { + if (action.kind !== 'act') continue; + const token = this.deps.ids.opaque(ACTION_TOKEN_LENGTH); + payloads.set(action.id, `${ACTION_TOKEN_PREFIX}${token}`); + rows.push({ + tokenHash: sha256Hex(token), + channelId, + notificationId: message.id, + actionId: action.id, + op: action.command.op, + args: action.command.args, + createdAt: now, + expiresAt: now + ACTION_TOKEN_TTL_MS, + usedAt: null, + }); + } + if (rows.length > 0) await this.deps.repos.notificationActionTokens.insert(rows); + return payloads; + } + + /** + * Checks and runs one press (spec 03 §9.6). Never throws: a failure becomes a `failed` answer. + * + * @returns What to tell the presser. + */ + async press(press: PressEvent): Promise { + try { + return await this.handle(press); + } catch (err) { + this.log.error('press failed', { err: serializeError(err) }); + return { outcome: 'failed', text: refusalText('failed', press, null), refused: true }; + } + } + + private async handle(press: PressEvent): Promise { + const now = this.deps.clock.now(); + const hash = sha256Hex(press.token); + const token = await this.deps.repos.notificationActionTokens.get(hash); + if (token === null) { + this.deps.counter?.add(1, { channel_kind: press.actor.platform, outcome: 'unknown' }); + this.log.warn('unknown button pressed', { + platform: press.actor.platform, + token: `${press.token.slice(0, 4)}…`, + }); + return { outcome: 'unknown', text: 'This button is no longer valid.', refused: true }; + } + const entry = this.deps.registry.get(token.channelId); + const channel = entry?.record ?? null; + const notification = await this.deps.repos.notifications.get(token.notificationId); + const message = notification === null ? null : decodeMessage(notification.messageJson); + const label = + message?.actions.find((a) => a.id === token.actionId)?.label ?? notification?.title ?? null; + const refuse = async ( + outcome: Exclude, + ): Promise => { + const text = refusalText(outcome, press, channel?.name ?? null); + await this.audit(press, token, channel, label, outcome, text, now); + return { outcome, text, refused: true }; + }; + if (channel === null) return refuse('disabled'); + const origin = pressOrigin(channel); + const platformMatches = channel.kind === press.actor.platform; + if (!platformMatches || (origin !== null && press.origin !== null && press.origin !== origin)) { + return refuse('wrong_channel'); + } + if (channel.rules.act_buttons !== true || channel.status !== 'active') + return refuse('disabled'); + if (token.usedAt !== null) return refuse('used'); + if (token.expiresAt <= now) return refuse('expired'); + if (notification === null || notification.state !== 'open') return refuse('stale'); + if (hasPresserIdentity(channel.kind)) { + const allowed = channel.rules.allow_list ?? []; + if (press.actor.id === null || !allowed.includes(press.actor.id)) + return refuse('not_allowed'); + } + if (!(await this.deps.repos.notificationActionTokens.claim(hash, now))) return refuse('used'); + return this.run(press, token, channel, label, now); + } + + private run( + press: PressEvent, + token: NotificationActionTokenRecord, + channel: NotificationChannelRecord, + label: string | null, + now: number, + ): Promise { + const executor = this.deps.executors.get(token.op); + return this.tracer.startActiveSpan( + 'notification.act', + { + attributes: { + 'browserhive.channel_kind': channel.kind, + 'browserhive.channel_id': channel.channelId, + 'browserhive.notification_id': token.notificationId, + 'browserhive.op': token.op, + }, + }, + async (span) => { + let outcome: NotificationActionOutcome; + let text: string; + try { + if (executor === undefined) { + outcome = 'failed'; + text = 'This action cannot be answered from a chat; open BrowserHive.'; + } else { + span.setAttribute('browserhive.scope', executor.scope); + text = await executor.run(token.args, actorOf(press.actor)); + outcome = 'done'; + } + } catch (err) { + if ( + isAppError(err) && + (err.code === 'ATTENTION_NOT_OPEN' || + err.code === 'CONFIRM_NOT_OPEN' || + err.code === 'NOT_FOUND') + ) { + outcome = 'stale'; + text = refusalText('stale', press, channel.name); + } else { + outcome = 'failed'; + const detail = serializeError(err).message; + text = `${refusalText('failed', press, channel.name)} (${clip(this.scrub(detail), 120)})`; + this.log.warn('press command failed', { op: token.op, err: serializeError(err) }); + } + } + span.setAttribute('browserhive.outcome', outcome); + if (outcome === 'failed') span.setStatus({ code: SpanStatusCode.ERROR, message: outcome }); + span.end(); + await this.audit(press, token, channel, label, outcome, text, now); + if (outcome === 'done') { + this.log.info('act button pressed', { channel: channel.name, op: token.op }); + } + return { outcome, text, refused: outcome !== 'done' }; + }, + ); + } + + private scrub(text: string): string { + return this.deps.redactor?.scrubText(text) ?? text; + } + + private async audit( + press: PressEvent, + token: NotificationActionTokenRecord, + channel: NotificationChannelRecord | null, + label: string | null, + outcome: NotificationActionOutcome, + detail: string, + at: number, + ): Promise { + const channelKind = channel?.kind ?? press.actor.platform; + this.deps.counter?.add(1, { channel_kind: channelKind, outcome }); + const record = await this.deps.repos.notificationActions.insert({ + at, + channelId: token.channelId, + channelName: channel?.name ?? token.channelId, + channelKind, + notificationId: token.notificationId, + actionId: token.actionId, + actionLabel: label === null ? null : clip(label, 80), + op: token.op, + args: token.args, + actor: actorOf(press.actor), + actorName: press.actor.name === null ? null : clip(this.scrub(press.actor.name), 128), + outcome, + detail: clip(this.scrub(detail), DETAIL_MAX), + }); + const row = await this.row(record, new Map()); + this.deps.bus?.publish('action.recorded', { type: 'action.recorded', action: row }); + } + + /** A page of the press audit, newest first. */ + async list(input: ActionListInput): Promise { + const beforeSeq = input.cursor === undefined ? undefined : decodeCursor(input.cursor); + const rows = await this.deps.repos.notificationActions.list({ + ...(input.channelId !== undefined && { channelId: input.channelId }), + ...(input.notificationId !== undefined && { notificationId: input.notificationId }), + ...(input.outcomes !== undefined && { outcomes: input.outcomes }), + ...(beforeSeq !== undefined && { beforeSeq }), + limit: input.limit + 1, + }); + const page = rows.slice(0, input.limit); + const titles = new Map(); + const items: ActionRow[] = []; + for (const r of page) items.push(await this.row(r, titles)); + const lastRow = page[page.length - 1]; + return { + items, + nextCursor: + rows.length > input.limit && lastRow !== undefined ? encodeCursor(lastRow.seq) : null, + }; + } + + private async row( + r: NotificationActionRecord, + titles: Map, + ): Promise { + let title: string | null = null; + if (r.notificationId !== null) { + const cached = titles.get(r.notificationId); + if (cached !== undefined) title = cached; + else { + title = (await this.deps.repos.notifications.get(r.notificationId))?.title ?? null; + titles.set(r.notificationId, title); + } + } + return { + seq: r.seq, + at: r.at, + channel_id: r.channelId, + channel_name: this.deps.registry.get(r.channelId)?.record.name ?? r.channelName, + channel_kind: r.channelKind, + notification_id: r.notificationId, + notification_title: title, + action_id: r.actionId, + action_label: r.actionLabel, + op: r.op, + actor: r.actor, + actor_name: r.actorName, + outcome: r.outcome, + detail: r.detail ?? ACTION_OUTCOME_TEXT[r.outcome] ?? null, + }; + } +} diff --git a/packages/core/src/app/notifications/channel-service.ts b/packages/core/src/app/notifications/channel-service.ts index a6247a4..d02ef63 100644 --- a/packages/core/src/app/notifications/channel-service.ts +++ b/packages/core/src/app/notifications/channel-service.ts @@ -11,6 +11,10 @@ import { type ChannelView, type DeliveryRow, DeliveryRow as DeliveryRowSchema, + type DiscordBotInfo, + type DiscordChannelsResponse, + type DiscordConnectResponse, + type DiscordConnectStatus, type PlatformRequest, type TelegramConnectResponse, type TelegramConnectStatus, @@ -19,10 +23,13 @@ import { AvailableChannelKind, CHANNEL_KIND_SPECS, checkChannelConfig, + checkChannelRules, + discordInviteUrl, type NotificationChannelRules, type NotificationMessage, NTFY_DEFAULT_SERVER, type PreviewSample, + SecretEnvName, TELEGRAM_TTL_MAX_MS, } from '@browserhive/contracts/notifications'; import { AppError } from '../../kernel/errors/app-error.ts'; @@ -38,7 +45,9 @@ import { type ChannelDelivery, type ChannelRenderer, ChannelSendError, + type DiscordSetup, type LinkBuilder, + type ListenerStatus, type TelegramSetup, type TelegramStart, } from '../../ports/notification-channel.ts'; @@ -50,6 +59,7 @@ import type { } from '../../ports/persistence/records.ts'; import type { Repositories, UnitOfWork } from '../../ports/persistence/unit-of-work.ts'; import type { DomainEvents } from '../events/catalog.ts'; +import type { ActionListInput, ActionPage, NotificationActionService } from './actions.ts'; import type { ChannelRegistry, RegisteredChannel } from './channel-registry.ts'; import { restrictContent } from './content-level.ts'; import { degrade } from './degrade.ts'; @@ -94,6 +104,12 @@ export interface ChannelServiceDeps { /** Registers a secret value with the redactor before it is used. */ readonly registerSecret?: (value: string) => void; readonly telegram?: TelegramSetup; + /** The Discord bot-mode setup calls (D-38). */ + readonly discord?: DiscordSetup; + /** The press listener state of a channel (`ChannelView.connection`), or `null` without one. */ + readonly connection?: (channelId: string) => ListenerStatus | null; + /** The act-button audit (`GET /channels/actions`). */ + readonly actions?: Pick; readonly redactor?: Redactor; /** Timer for debounced feed events (defaults to `setTimeout`). */ readonly schedule?: (fn: () => void, ms: number) => void; @@ -128,6 +144,17 @@ interface ConnectSession { endedAt: number | null; } +interface DiscordConnectSession { + readonly id: string; + readonly tokenEnv: string; + readonly expiresAt: number; + readonly abort: AbortController; + status: DiscordConnectStatus['status']; + user: { readonly id: string; readonly name: string } | null; + error: string | null; + endedAt: number | null; +} + /** Capabilities in wire form. */ export function capabilitiesDto(c: ChannelCapabilities): ChannelCapabilitiesDto { return { @@ -178,8 +205,14 @@ export function targetHint( : `chat ${last(chat, 4)}`; return t['thread_id'] === undefined ? base : `${base} · topic ${t['thread_id']}`; } - case 'discord': - return `${record.mode ?? 'webhook'} from $${s['webhook'] ?? '?'}`; + case 'discord': { + if (record.mode === 'bot') { + const room = + t['channel_name'] ?? (t['channel_id'] === undefined ? '?' : last(t['channel_id'], 4)); + return `bot · #${room}${t['guild_name'] === undefined ? '' : ` in ${t['guild_name']}`}`; + } + return `webhook from $${s['webhook'] ?? '?'}`; + } case 'ntfy': { const server = hostPath(t['server'] ?? NTFY_DEFAULT_SERVER); const topic = t['topic'] ?? (s['topic'] === undefined ? '?' : `$${s['topic']}`); @@ -232,6 +265,7 @@ function decodeCursor(cursor: string): number { export class ChannelService { private readonly log: Logger; private readonly connects = new Map(); + private readonly discordConnects = new Map(); private readonly pendingChannels = new Set(); private channelTimer = false; @@ -327,6 +361,7 @@ export class ChannelService { last_delivery_at: s?.lastAt ?? null, last_status: s?.lastStatus ?? null, }, + connection: this.deps.connection?.(r.channelId) ?? null, }; } @@ -341,19 +376,15 @@ export class ChannelService { readonly secretRefs: Readonly>; readonly rules: NotificationChannelRules; }): void { - if (input.kind === 'discord' && input.mode === 'bot') { - throw new AppError('CHANNEL_KIND_UNAVAILABLE', { - kind: 'discord', - mode: 'bot', - mode_text: ' in bot mode', - }); - } - const issues = checkChannelConfig({ - kind: input.kind, - mode: input.mode, - target: input.target, - secretRefs: input.secretRefs, - }).map((p) => ({ path: p.field, message: p.message, code: 'custom' })); + const issues = [ + ...checkChannelConfig({ + kind: input.kind, + mode: input.mode, + target: input.target, + secretRefs: input.secretRefs, + }), + ...checkChannelRules(input), + ].map((p) => ({ path: p.field, message: p.message, code: 'custom' })); if (input.kind === 'telegram') { for (const [category, ms] of Object.entries(input.rules.ttl_ms ?? {})) { if (ms !== undefined && ms > TELEGRAM_TTL_MAX_MS) { @@ -699,13 +730,19 @@ export class ChannelService { mode = request.mode ?? spec.defaultMode; target = request.target ?? {}; rules = request.rules ?? {}; + // Names only; anything that is not a variable name (a pasted value) is never echoed back. + secretRefs = Object.fromEntries( + Object.entries(request.secret_refs ?? {}).filter( + ([, name]) => SecretEnvName.safeParse(name).success, + ), + ); } const renderer = this.deps.renderers.get(kind); const parsedKind = AvailableChannelKind.safeParse(kind); if (renderer === undefined || !parsedKind.success) { throw new AppError('CHANNEL_KIND_UNAVAILABLE', { kind, mode_text: '' }); } - const capabilities = renderer.capabilities(mode); + const capabilities = renderer.capabilities({ mode, target, secretRefs, rules }); const now = this.deps.clock.now(); const plain = sampleMessage(request.sample, { now }); const withImage = wantsImages(rules, plain.category) @@ -743,7 +780,14 @@ export class ChannelService { message: shown, requests, local_links: this.deps.links.local, - notes: this.notes(parsedKind.data, rules, capabilities, plain.category, request.sample), + notes: this.notes( + parsedKind.data, + rules, + capabilities, + plain.category, + request.sample, + target, + ), }; } @@ -753,6 +797,7 @@ export class ChannelService { caps: ChannelCapabilities, category: NotificationCategory, sample: PreviewSample, + target: Readonly>, ): string[] { const notes: string[] = []; if (this.deps.links.local) { @@ -760,14 +805,25 @@ export class ChannelService { 'Links open only on this computer. To open them from your phone, set publicUrl to the address where you reach this dashboard.', ); } - if (!caps.actButtons && (sample === 'attention' || sample === 'vault-confirm')) { + const answerable = sample === 'attention' || sample === 'vault-confirm'; + if (answerable && caps.actButtons && kind !== 'webhook') { + notes.push( + kind === 'ntfy' + ? 'Tapping a button answers the request: ntfy posts it to the reply topic, BrowserHive acts and updates the notification.' + : "Pressing a button answers the request from the chat; only the people on the channel's allow-list can.", + ); + } else if (answerable && caps.actButtons) { + notes.push( + 'The act actions are sent as they are in the contract; your receiver answers through the BrowserHive API.', + ); + } else if (answerable) { notes.push('Approve and Reject open BrowserHive, where you answer the request.'); } if (rules.images?.[category] === true && !wantsImages(rules, category)) { notes.push('Screenshots are on, but they need the content level "full".'); } - if (kind === 'ntfy' && wantsImages(rules, category)) { - const server = NTFY_DEFAULT_SERVER; + const server = (target['server'] ?? NTFY_DEFAULT_SERVER).replace(/\/+$/, ''); + if (kind === 'ntfy' && wantsImages(rules, category) && server === NTFY_DEFAULT_SERVER) { notes.push( `On ${server.replace('https://', '')} attachments are stored on the public server for 3 hours; a self-hosted ntfy keeps screenshots private.`, ); @@ -982,6 +1038,8 @@ export class ChannelService { stop(): void { for (const session of this.connects.values()) session.abort.abort(); this.connects.clear(); + for (const session of this.discordConnects.values()) session.abort.abort(); + this.discordConnects.clear(); } private pruneConnects(): void { @@ -990,6 +1048,158 @@ export class ChannelService { if (session.endedAt !== null && now - session.endedAt > CONNECT_KEEP_MS) this.connects.delete(id); } + for (const [id, session] of this.discordConnects) { + if (session.endedAt !== null && now - session.endedAt > CONNECT_KEEP_MS) + this.discordConnects.delete(id); + } + } + + // --------------------------------------------------------------------------------------------- + // Discord bot setup (D-38) + // --------------------------------------------------------------------------------------------- + + private discordToken(tokenEnv: string): { setup: DiscordSetup; token: string } { + const setup = this.deps.discord; + if (setup === undefined) { + throw new AppError('CHANNEL_KIND_UNAVAILABLE', { + kind: 'discord', + mode: 'bot', + mode_text: ' in bot mode', + }); + } + const token = this.deps.env(tokenEnv); + if (token === undefined || token === '') { + throw new AppError('CHANNEL_NOT_READY', { + problem: `${tokenEnv} is not set.`, + missing: [tokenEnv], + }); + } + this.deps.registerSecret?.(token); + return { setup, token }; + } + + private platformError(kind: string, err: unknown): AppError { + const code = err instanceof ChannelSendError ? err.code : 'unavailable'; + return new AppError('CHANNEL_PLATFORM_ERROR', { + kind, + code, + detail: this.scrub(serializeError(err).message), + }); + } + + /** + * Who the bot is, its invite link (minimal permissions) and the servers it is in. + * + * @throws AppError `CHANNEL_NOT_READY` (unset variable), `CHANNEL_PLATFORM_ERROR`. + */ + async discordBot(tokenEnv: string): Promise { + const { setup, token } = this.discordToken(tokenEnv); + try { + const bot = await setup.bot(token); + return { + application_id: bot.applicationId, + bot_id: bot.botId, + bot_username: bot.username, + invite_url: discordInviteUrl(bot.applicationId), + guilds: bot.guilds.map((g) => ({ id: g.id, name: g.name })), + }; + } catch (err) { + throw this.platformError('discord', err); + } + } + + /** + * The text channels of one of the bot's servers. + * + * @throws AppError `CHANNEL_NOT_READY`, `CHANNEL_PLATFORM_ERROR`. + */ + async discordChannels(tokenEnv: string, guildId: string): Promise { + const { setup, token } = this.discordToken(tokenEnv); + try { + const channels = await setup.channels(token, guildId); + return { channels: channels.map((c) => ({ ...c })) }; + } catch (err) { + throw this.platformError('discord', err); + } + } + + /** + * Starts the Discord account link: the bot posts a "This is me" button in the channel and the + * server waits up to two minutes for its press. A new link for the same variable cancels the + * previous one. + * + * @throws AppError `CHANNEL_NOT_READY`, `CHANNEL_PLATFORM_ERROR`. + */ + async discordConnect(tokenEnv: string, channelId: string): Promise { + const { setup, token } = this.discordToken(tokenEnv); + this.pruneConnects(); + for (const session of this.discordConnects.values()) { + if (session.tokenEnv === tokenEnv && session.status === 'waiting') { + session.abort.abort(); + session.status = 'expired'; + session.endedAt = this.deps.clock.now(); + } + } + const id = this.deps.ids.opaque(16); + const expiresAt = this.deps.clock.now() + TELEGRAM_CONNECT_MS; + const session: DiscordConnectSession = { + id, + tokenEnv, + expiresAt, + abort: new AbortController(), + status: 'waiting', + user: null, + error: null, + endedAt: null, + }; + this.discordConnects.set(id, session); + void setup + .claim(token, channelId, { signal: session.abort.signal, deadline: expiresAt }) + .then((user) => { + if (session.status !== 'waiting') return; + session.user = user; + session.status = user === null ? 'expired' : 'connected'; + session.endedAt = this.deps.clock.now(); + if (user !== null) this.log.info('discord account linked', {}); + }) + .catch((err: unknown) => { + if (session.status !== 'waiting') return; + session.status = 'failed'; + session.error = this.scrub(serializeError(err).message); + session.endedAt = this.deps.clock.now(); + }); + return { connect_id: id, expires_at: expiresAt }; + } + + /** + * The state of one Discord account link. + * + * @throws AppError `NOT_FOUND` for an unknown or forgotten id. + */ + discordConnectStatus(connectId: string): DiscordConnectStatus { + this.pruneConnects(); + const session = this.discordConnects.get(connectId); + if (session === undefined) throw new AppError('NOT_FOUND', {}); + if (session.status === 'waiting' && this.deps.clock.now() > session.expiresAt + 5_000) { + session.status = 'expired'; + session.endedAt = this.deps.clock.now(); + } + return { + status: session.status, + user: session.user, + error: session.error, + expires_at: session.expiresAt, + }; + } + + // --------------------------------------------------------------------------------------------- + // Act-button audit (D-41) + // --------------------------------------------------------------------------------------------- + + /** A page of the press audit, newest first. */ + async actions(input: ActionListInput): Promise { + if (this.deps.actions === undefined) return { items: [], nextCursor: null }; + return this.deps.actions.list(input); } // --------------------------------------------------------------------------------------------- diff --git a/packages/core/src/app/notifications/index.ts b/packages/core/src/app/notifications/index.ts index e60e983..33a6866 100644 --- a/packages/core/src/app/notifications/index.ts +++ b/packages/core/src/app/notifications/index.ts @@ -1,5 +1,19 @@ /** @module app/notifications — public surface of the notification subsystem (D-16, D-32, D-34): producers, the message contract builders, content levels, `degrade`, routing, the channel registry, the delivery outbox and the inbox service. */ +export { + NotificationActionListeners, + type NotificationActionListenersDeps, +} from './action-listeners.ts'; +export { + type ActionCounter, + type ActionExecutor, + type ActionListInput, + type ActionPage, + actorOf, + NotificationActionService, + type NotificationActionServiceDeps, + pressOrigin, +} from './actions.ts'; export { type ChannelAdapterFactory, type ChannelFactoryContext, diff --git a/packages/core/src/app/notifications/message.test.ts b/packages/core/src/app/notifications/message.test.ts index e82c380..def6aca 100644 --- a/packages/core/src/app/notifications/message.test.ts +++ b/packages/core/src/app/notifications/message.test.ts @@ -12,7 +12,13 @@ import { reviseMessage, scrubMessage, } from './message.ts'; -import { draftFor, type ProducedEvent, revisionFor } from './producers.ts'; +import { + actorPhrase, + draftFor, + type ProducedEvent, + requestSettled, + revisionFor, +} from './producers.ts'; import { attentionCreated, attentionResolved, @@ -223,6 +229,24 @@ describe('producer messages', () => { }); }); +describe('who answered (D-41)', () => { + it('names a dashboard principal, a chat user with the platform, or the ntfy reply topic', () => { + expect(actorPhrase('admin')).toBe('by admin'); + expect(actorPhrase('telegram:123456789')).toBe('on Telegram by 123456789'); + expect(actorPhrase('discord:4455')).toBe('on Discord by 4455'); + expect(actorPhrase('ntfy:topic-b')).toBe('from ntfy'); + const revision = requestSettled('attention', { + requestId: 'a-000000000001', + status: 'rejected', + resolvedBy: 'telegram:42', + createdAt: 0, + resolvedAt: 42_000, + waitedMs: 42_000, + }); + expect(revision?.change.summary).toBe('Rejected on Telegram by 42 after 42 s'); + }); +}); + describe('revisions', () => { const table = [ [ diff --git a/packages/core/src/app/notifications/outbox.ts b/packages/core/src/app/notifications/outbox.ts index 65281ab..53f59f3 100644 --- a/packages/core/src/app/notifications/outbox.ts +++ b/packages/core/src/app/notifications/outbox.ts @@ -110,6 +110,17 @@ export interface NotificationOutboxDeps { * so the live delivery log can refresh those rows. Must not throw. */ readonly onDeliveryChange?: (channelId: string, notificationId: string) => void; + /** + * Mints the command tokens of a message's act buttons for a channel that receives presses + * (D-41), before the platform call. Absent: act buttons carry no tokens. + */ + readonly actions?: { + mint( + channelId: string, + message: NotificationMessage, + now: number, + ): Promise>; + }; } /** Summary of one pass (tests, logs). */ @@ -386,9 +397,10 @@ export class NotificationOutbox { 'covered', job.seq, ); - const delivery = await this.delivery(job, entry, message); + const shaped = await this.delivery(job, entry, message); const started = this.deps.clock.now(); try { + const delivery = await this.withTokens(entry, shaped, started); const result = await this.call(entry, job, message.revision, () => job.op === 'edit' && cm !== null && adapter.edit !== undefined ? adapter.edit(cm.messageRef, delivery) @@ -443,6 +455,22 @@ export class NotificationOutbox { return { message: degraded, links: this.deps.links, replyTo }; } + /** + * Adds the act buttons' command tokens when the channel receives presses and the (degraded) + * message still carries act actions. The tokens are stored before the platform call. + */ + private async withTokens( + entry: RegisteredChannel, + delivery: ChannelDelivery, + now: number, + ): Promise { + const mint = this.deps.actions; + if (mint === undefined || entry.adapter?.presses === undefined) return delivery; + if (!delivery.message.actions.some((a) => a.kind === 'act')) return delivery; + const actTokens = await mint.mint(entry.record.channelId, delivery.message, now); + return { ...delivery, actTokens }; + } + /** One platform call inside a `notification.deliver` span. */ private call( entry: RegisteredChannel, diff --git a/packages/core/src/app/notifications/producers.ts b/packages/core/src/app/notifications/producers.ts index f7accfd..d211e2c 100644 --- a/packages/core/src/app/notifications/producers.ts +++ b/packages/core/src/app/notifications/producers.ts @@ -367,6 +367,26 @@ function vaultConfirmCreated(payload: DomainEvents['vault.confirm.created']): No }; } +const PLATFORM_NAMES: Readonly> = { + telegram: 'Telegram', + discord: 'Discord', + ntfy: 'ntfy', +}; + +/** + * How a settled request names who answered: "by admin" for a dashboard principal, "on Telegram by + * 123456789" for an act button (`telegram:`), "from ntfy" for the ntfy reply topic (D-41). + * + * @returns The phrase, without a leading space. + */ +export function actorPhrase(resolvedBy: string): string { + const match = /^(telegram|discord|ntfy):(.+)$/.exec(resolvedBy); + if (match === null) return `by ${resolvedBy}`; + const platform = PLATFORM_NAMES[match[1] ?? ''] ?? match[1]; + if (match[1] === 'ntfy') return `from ${platform}`; + return `on ${platform} by ${match[2]}`; +} + /** What the revision of a settled operator request is built from (a wire row or a stored record). */ export interface SettledRequestFacts { readonly requestId: string; @@ -403,7 +423,7 @@ export function requestSettled( const waited = f.waitedMs ?? (f.resolvedAt === null ? null : f.resolvedAt - f.createdAt); const after = waited === null ? '' : ` after ${formatDuration(waited)}`; // Orphan recovery and shutdown settle a request with no actor: the summary names none. - const by = f.resolvedBy === null ? '' : ` by ${f.resolvedBy}`; + const by = f.resolvedBy === null ? '' : ` ${actorPhrase(f.resolvedBy)}`; const vault = prefix === 'vault'; const outcome: { state: NotificationState; label: string; summary: string } = (() => { switch (f.status) { diff --git a/packages/core/src/infra/notifications/discord-gateway.ts b/packages/core/src/infra/notifications/discord-gateway.ts new file mode 100644 index 0000000..19ea96c --- /dev/null +++ b/packages/core/src/infra/notifications/discord-gateway.ts @@ -0,0 +1,594 @@ +/** @module infra/notifications/discord-gateway — the Discord gateway subset BrowserHive needs for act buttons (spec 03 §9.6, D-38, D-41): one outbound WebSocket per bot token (Hello, Identify with intents 0, heartbeats with zombie detection, Resume, Reconnect, Invalid Session, fatal close codes), `INTERACTION_CREATE` presses answered within Discord's 3 seconds, and the setup's "This is me" claim. No dependency: Bun's WebSocket client. */ + +import { ACTION_PAYLOAD_RE } from '@browserhive/contracts/notifications'; +import { serializeError } from '../../kernel/errors/serialize-error.ts'; +import type { Logger } from '../../ports/logger.ts'; +import { + ChannelSendError, + type ListenerStatus, + type PressAnswer, + type PressHandler, + type PressSource, +} from '../../ports/notification-channel.ts'; +import { callPlatform, type FetchFn } from './http.ts'; + +/** Discord's REST base (API v10). */ +export const DISCORD_API_BASE = 'https://discord.com/api/v10'; +/** Prefix of the setup's "This is me" button (`bh1c:`). */ +export const CLAIM_PREFIX = 'bh1c:'; + +/** Gateway opcodes used here. */ +const OP = { + dispatch: 0, + heartbeat: 1, + identify: 2, + resume: 6, + reconnect: 7, + invalidSession: 9, + hello: 10, + heartbeatAck: 11, +} as const; + +/** Close codes after which reconnecting cannot help (auth failed, bad intents or version). */ +const FATAL_CLOSE = new Set([4004, 4010, 4011, 4012, 4013, 4014]); +/** Close codes after which the session cannot be resumed. */ +const NO_RESUME_CLOSE = new Set([4007, 4009]); + +/** Interaction callback types. */ +const CALLBACK = { message: 4, deferredMessage: 5, deferredUpdate: 6 } as const; +/** Ephemeral message flag. */ +const EPHEMERAL = 64; + +/** The minimal WebSocket surface the gateway uses (Bun's global `WebSocket`; fakes in tests). */ +export interface GatewaySocket { + send(data: string): void; + close(code?: number, reason?: string): void; + onopen: ((ev: unknown) => void) | null; + onmessage: ((ev: { data: unknown }) => void) | null; + onclose: ((ev: { code: number; reason?: string }) => void) | null; + onerror: ((ev: unknown) => void) | null; +} + +/** Options of {@link DiscordGatewayHub}. */ +export interface DiscordGatewayOptions { + readonly fetch?: FetchFn; + /** REST base; the fakes pass their own. */ + readonly apiBase?: string; + /** Opens a socket (default: Bun's `WebSocket`). */ + readonly socket?: (url: string) => GatewaySocket; + readonly logger?: Logger; + readonly now?: () => number; + /** Uniform [0, 1) for the first heartbeat's jitter. */ + readonly random?: () => number; + /** How long a connection outlives its last channel (a registry reload re-subscribes); 5 s. */ + readonly lingerMs?: number; + /** First and longest wait between reconnects; 1 s and 60 s. */ + readonly backoffMs?: { readonly min: number; readonly max: number }; + /** Retry after a fatal close (a refused token); 5 min. */ + readonly fatalRetryMs?: number; + /** A press's command gets this long before the answer is deferred (Discord allows 3 s). */ + readonly answerWithinMs?: number; +} + +type Json = Record; + +function obj(value: unknown): Json | null { + return value !== null && typeof value === 'object' ? (value as Json) : null; +} + +interface Subscriber { + readonly channelId: string; + readonly handler: PressHandler; + readonly onStatus: (status: ListenerStatus) => void; +} + +interface Claim { + readonly code: string; + resolve(user: { readonly id: string; readonly name: string } | null): void; +} + +/** Who sent an interaction. */ +function userOf(interaction: Json): { id: string | null; name: string | null } { + const user = obj(obj(interaction['member'])?.['user']) ?? obj(interaction['user']); + const id = user?.['id']; + const name = + typeof user?.['global_name'] === 'string' + ? user['global_name'] + : typeof user?.['username'] === 'string' + ? user['username'] + : null; + return { id: typeof id === 'string' ? id : null, name }; +} + +type Opts = Required> & { + readonly fetch: FetchFn; + readonly socket: (url: string) => GatewaySocket; + readonly logger: Logger | null; +}; + +class GatewayConnection { + readonly subscribers = new Set(); + readonly claims = new Set(); + private status: ListenerStatus; + private socket: GatewaySocket | null = null; + private seq: number | null = null; + private sessionId: string | null = null; + private resumeUrl: string | null = null; + private gatewayUrl: string | null = null; + private heartbeat: ReturnType | null = null; + private firstBeat: ReturnType | null = null; + private reconnectTimer: ReturnType | null = null; + private lingerTimer: ReturnType | null = null; + private acked = true; + private running = false; + private delay: number; + + constructor( + private readonly token: string, + private readonly hub: DiscordGatewayHub, + private readonly opts: Opts, + ) { + this.status = { state: 'connecting', since: opts.now(), detail: null }; + this.delay = opts.backoffMs.min; + } + + current(): ListenerStatus { + return this.status; + } + + private wanted(): boolean { + return this.subscribers.size > 0 || this.claims.size > 0; + } + + wake(): void { + if (this.lingerTimer !== null) { + clearTimeout(this.lingerTimer); + this.lingerTimer = null; + } + if (this.running) return; + this.running = true; + this.setStatus('connecting', null); + void this.connect(); + } + + release(): void { + if (this.wanted() || this.lingerTimer !== null) return; + this.lingerTimer = setTimeout(() => { + this.lingerTimer = null; + if (!this.wanted()) this.halt(); + }, this.opts.lingerMs); + } + + halt(): void { + this.running = false; + for (const t of [this.lingerTimer, this.reconnectTimer, this.firstBeat]) { + if (t !== null) clearTimeout(t); + } + this.lingerTimer = null; + this.reconnectTimer = null; + this.stopHeartbeat(); + const socket = this.socket; + this.socket = null; + socket?.close(1000, 'stopped'); + for (const claim of this.claims) claim.resolve(null); + this.claims.clear(); + this.hub.forget(this.token, this); + } + + private setStatus(state: ListenerStatus['state'], detail: string | null): void { + if (this.status.state === state && this.status.detail === detail) return; + this.status = { state, since: this.opts.now(), detail }; + for (const sub of this.subscribers) { + try { + sub.onStatus(this.status); + } catch { + // a listener's callback never stops the gateway + } + } + } + + private rest(path: string, init: { method: string; body?: unknown; auth: boolean }) { + return callPlatform( + { + url: `${this.opts.apiBase}${path}`, + method: init.method, + headers: { + ...(init.auth && { authorization: `Bot ${this.token}` }), + ...(init.body !== undefined && { 'content-type': 'application/json' }), + }, + ...(init.body !== undefined && { body: JSON.stringify(init.body) }), + }, + { fetch: this.opts.fetch, secrets: [this.token], platform: 'Discord' }, + ); + } + + private async connect(): Promise { + if (!this.running) return; + let url = this.resumeUrl; + if (url === null || this.sessionId === null) { + if (this.gatewayUrl === null) { + try { + const answer = await this.rest('/gateway/bot', { method: 'GET', auth: true }); + const found = obj(answer.json)?.['url']; + if (typeof found !== 'string') throw new ChannelSendError('rejected', 'no gateway url'); + this.gatewayUrl = found; + } catch (err) { + const auth = err instanceof ChannelSendError && err.code === 'auth'; + this.retry( + auth ? 'offline' : 'reconnecting', + auth ? 'Discord refused the bot token.' : 'Discord could not be reached; retrying.', + auth ? this.opts.fatalRetryMs : undefined, + ); + return; + } + } + url = this.gatewayUrl; + } + if (!this.running) return; + let socket: GatewaySocket; + try { + socket = this.opts.socket(`${url.replace(/\/+$/, '')}/?v=10&encoding=json`); + } catch (err) { + this.opts.logger?.warn('discord gateway failed', { err: serializeError(err) }); + this.retry('reconnecting', 'Discord could not be reached; retrying.'); + return; + } + this.socket = socket; + socket.onmessage = (ev) => { + if (this.socket !== socket) return; + try { + const text = typeof ev.data === 'string' ? ev.data : String(ev.data); + this.receive(JSON.parse(text) as Json); + } catch (err) { + this.opts.logger?.warn('discord gateway failed', { err: serializeError(err) }); + } + }; + socket.onclose = (ev) => { + if (this.socket !== socket) return; + this.socket = null; + this.closed(ev.code); + }; + socket.onerror = () => undefined; // onclose follows + } + + private send(payload: Json): void { + try { + this.socket?.send(JSON.stringify(payload)); + } catch { + // the close handler reconnects + } + } + + private stopHeartbeat(): void { + if (this.heartbeat !== null) clearInterval(this.heartbeat); + if (this.firstBeat !== null) clearTimeout(this.firstBeat); + this.heartbeat = null; + this.firstBeat = null; + } + + private beat(): void { + if (!this.acked) { + // A zombie connection: no ACK since the last heartbeat. Reconnect and resume. + this.opts.logger?.warn('discord gateway zombie', {}); + const socket = this.socket; + this.socket = null; + socket?.close(4000, 'zombie'); + this.closed(4000); + return; + } + this.acked = false; + this.send({ op: OP.heartbeat, d: this.seq }); + } + + private receive(payload: Json): void { + const op = payload['op']; + if (typeof payload['s'] === 'number') this.seq = payload['s']; + switch (op) { + case OP.hello: { + const interval = Number(obj(payload['d'])?.['heartbeat_interval'] ?? 41_250); + this.stopHeartbeat(); + this.acked = true; + this.firstBeat = setTimeout( + () => { + this.beat(); + this.heartbeat = setInterval(() => this.beat(), interval); + }, + Math.floor(interval * this.opts.random()), + ); + if (this.sessionId !== null && this.seq !== null) { + this.send({ + op: OP.resume, + d: { token: this.token, session_id: this.sessionId, seq: this.seq }, + }); + } else { + this.send({ + op: OP.identify, + d: { + token: this.token, + intents: 0, + properties: { os: process.platform, browser: 'BrowserHive', device: 'BrowserHive' }, + }, + }); + } + return; + } + case OP.heartbeatAck: + this.acked = true; + return; + case OP.heartbeat: + this.send({ op: OP.heartbeat, d: this.seq }); + return; + case OP.reconnect: { + const socket = this.socket; + this.socket = null; + socket?.close(4000, 'reconnect'); + this.closed(4000, true); + return; + } + case OP.invalidSession: { + if (payload['d'] !== true) { + this.sessionId = null; + this.seq = null; + this.resumeUrl = null; + } + const socket = this.socket; + this.socket = null; + socket?.close(4000, 'invalid session'); + this.closed(4000, true); + return; + } + case OP.dispatch: { + const type = payload['t']; + const d = obj(payload['d']) ?? {}; + if (type === 'READY') { + this.sessionId = typeof d['session_id'] === 'string' ? d['session_id'] : null; + this.resumeUrl = + typeof d['resume_gateway_url'] === 'string' ? d['resume_gateway_url'] : null; + this.delay = this.opts.backoffMs.min; + this.setStatus('connected', null); + } else if (type === 'RESUMED') { + this.delay = this.opts.backoffMs.min; + this.setStatus('connected', null); + } else if (type === 'INTERACTION_CREATE') { + void this.interaction(d).catch((err: unknown) => + this.opts.logger?.warn('discord press failed', { err: serializeError(err) }), + ); + } + return; + } + } + } + + private closed(code: number, immediate = false): void { + this.stopHeartbeat(); + if (!this.running) return; + if (FATAL_CLOSE.has(code)) { + this.sessionId = null; + this.seq = null; + this.resumeUrl = null; + this.retry( + 'offline', + code === 4004 ? 'Discord refused the bot token.' : `Discord closed the gateway (${code}).`, + this.opts.fatalRetryMs, + ); + return; + } + if (NO_RESUME_CLOSE.has(code)) { + this.sessionId = null; + this.seq = null; + this.resumeUrl = null; + } + this.retry('reconnecting', 'Reconnecting to Discord.', immediate ? 0 : undefined); + } + + private retry(state: ListenerStatus['state'], detail: string, waitMs?: number): void { + this.setStatus(state, detail); + if (!this.running) return; + const wait = waitMs ?? this.delay; + if (waitMs === undefined) this.delay = Math.min(this.opts.backoffMs.max, this.delay * 2); + if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + void this.connect(); + }, wait); + } + + private async callback(interaction: Json, body: Json): Promise { + const id = interaction['id']; + const token = interaction['token']; + if (typeof id !== 'string' || typeof token !== 'string') return; + await this.rest(`/interactions/${id}/${token}/callback`, { + method: 'POST', + body, + auth: false, + }); + } + + private async followUp(interaction: Json, content: string): Promise { + const app = interaction['application_id']; + const token = interaction['token']; + if (typeof app !== 'string' || typeof token !== 'string') return; + await this.rest(`/webhooks/${app}/${token}/messages/@original`, { + method: 'PATCH', + body: { content }, + auth: false, + }); + } + + private async interaction(interaction: Json): Promise { + if (interaction['type'] !== 3) return; // only message components carry our buttons + const data = obj(interaction['data']); + const customId = typeof data?.['custom_id'] === 'string' ? data['custom_id'] : ''; + const user = userOf(interaction); + if (customId.startsWith(CLAIM_PREFIX)) { + const code = customId.slice(CLAIM_PREFIX.length); + const claim = [...this.claims].find((c) => c.code === code); + if (claim === undefined || user.id === null) { + await this.callback(interaction, { + type: CALLBACK.message, + data: { content: 'This link has expired; start again in BrowserHive.', flags: EPHEMERAL }, + }); + return; + } + await this.callback(interaction, { + type: CALLBACK.message, + data: { + content: 'Linked. You can now answer BrowserHive requests in this channel.', + flags: EPHEMERAL, + }, + }); + this.claims.delete(claim); + claim.resolve({ id: user.id, name: user.name ?? user.id }); + return; + } + const token = ACTION_PAYLOAD_RE.exec(customId)?.[1]; + if (token === undefined) { + await this.callback(interaction, { type: CALLBACK.deferredUpdate }); + return; + } + const channelId = + typeof interaction['channel_id'] === 'string' ? interaction['channel_id'] : null; + const subs = [...this.subscribers]; + const sub = subs.find((s) => s.channelId === channelId) ?? subs[0]; + if (sub === undefined) { + await this.callback(interaction, { + type: CALLBACK.message, + data: { content: 'Answering from Discord is switched off.', flags: EPHEMERAL }, + }); + return; + } + const answer = sub.handler({ + token, + origin: channelId, + actor: { platform: 'discord', id: user.id, name: user.name }, + }); + // Discord allows 3 seconds: answer directly when the command is quick, else defer and edit. + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), this.opts.answerWithinMs); + }); + const quick: PressAnswer | null = await Promise.race([answer, timeout]); + clearTimeout(timer); + if (quick !== null) { + await this.callback(interaction, { + type: CALLBACK.message, + data: { content: quick.text, flags: EPHEMERAL }, + }); + return; + } + await this.callback(interaction, { + type: CALLBACK.deferredMessage, + data: { flags: EPHEMERAL }, + }); + const late = await answer; + await this.followUp(interaction, late.text); + } +} + +/** + * The gateway connections, one per bot token, shared by the bot's channels (act buttons) and the + * setup's "This is me" claim. + */ +export class DiscordGatewayHub { + private readonly bots = new Map(); + private readonly opts: Opts; + + constructor(options: DiscordGatewayOptions = {}) { + this.opts = { + fetch: options.fetch ?? fetch, + apiBase: (options.apiBase ?? DISCORD_API_BASE).replace(/\/+$/, ''), + socket: options.socket ?? ((url) => new WebSocket(url) as unknown as GatewaySocket), + logger: options.logger?.child({ module: 'notifications' }) ?? null, + now: options.now ?? Date.now, + random: options.random ?? Math.random, + lingerMs: options.lingerMs ?? 5_000, + backoffMs: options.backoffMs ?? { min: 1_000, max: 60_000 }, + fatalRetryMs: options.fatalRetryMs ?? 5 * 60_000, + answerWithinMs: options.answerWithinMs ?? 2_000, + }; + } + + private connection(token: string): GatewayConnection { + let bot = this.bots.get(token); + if (bot === undefined) { + bot = new GatewayConnection(token, this, this.opts); + this.bots.set(token, bot); + } + return bot; + } + + /** @internal Drops a stopped connection. */ + forget(token: string, bot: GatewayConnection): void { + if (this.bots.get(token) === bot) this.bots.delete(token); + } + + /** + * The press source of one channel: presses of buttons in `channelId` go to its handler. + * + * @returns A {@link PressSource}. + */ + pressSource(token: string, channelId: string): PressSource { + return { + listen: (handler, onStatus) => { + const bot = this.connection(token); + const sub: Subscriber = { channelId, handler, onStatus }; + bot.subscribers.add(sub); + bot.wake(); + return () => { + bot.subscribers.delete(sub); + bot.release(); + }; + }, + status: () => + this.bots.get(token)?.current() ?? { + state: 'connecting', + since: this.opts.now(), + detail: null, + }, + }; + } + + /** + * Waits for the press of a "This is me" button whose `custom_id` is `bh1c:`. + * + * @returns Who pressed it, or `null` on abort or deadline. + */ + waitForClaim( + token: string, + code: string, + options: { readonly signal: AbortSignal; readonly deadline: number }, + ): Promise<{ readonly id: string; readonly name: string } | null> { + const bot = this.connection(token); + return new Promise((resolve) => { + let done = false; + const claim: Claim = { + code, + resolve: (user) => { + if (done) return; + done = true; + clearTimeout(timer); + bot.claims.delete(claim); + resolve(user); + bot.release(); + }, + }; + const timer = setTimeout( + () => claim.resolve(null), + Math.max(0, options.deadline - this.opts.now()), + ); + options.signal.addEventListener('abort', () => claim.resolve(null)); + if (options.signal.aborted) { + claim.resolve(null); + return; + } + bot.claims.add(claim); + bot.wake(); + }); + } + + /** Closes every connection (shutdown). */ + stop(): void { + for (const bot of [...this.bots.values()]) bot.halt(); + this.bots.clear(); + } +} diff --git a/packages/core/src/infra/notifications/discord-setup.ts b/packages/core/src/infra/notifications/discord-setup.ts new file mode 100644 index 0000000..38babcb --- /dev/null +++ b/packages/core/src/infra/notifications/discord-setup.ts @@ -0,0 +1,154 @@ +/** @module infra/notifications/discord-setup — the Discord bot-mode setup calls (spec 03 §4.8.1, D-38): the bot's identity and servers, a server's text channels, and the "This is me" claim over the shared gateway. */ + +import { randomBytes } from 'node:crypto'; +import { + ChannelSendError, + type DiscordBotIdentity, + type DiscordChannelInfo, + type DiscordSetup, +} from '../../ports/notification-channel.ts'; +import { refineDiscord } from './discord.ts'; +import { CLAIM_PREFIX, DISCORD_API_BASE, DiscordGatewayHub } from './discord-gateway.ts'; +import { callPlatform, type FetchFn } from './http.ts'; + +/** Options of {@link createDiscordSetup}. */ +export interface DiscordSetupOptions { + readonly fetch?: FetchFn; + readonly apiBase?: string; + /** The gateway connections shared with the channels' act buttons (a private hub otherwise). */ + readonly gateway?: DiscordGatewayHub; +} + +type Json = Record; + +function obj(value: unknown): Json | null { + return value !== null && typeof value === 'object' ? (value as Json) : null; +} + +function str(value: unknown): string | null { + return typeof value === 'string' && value !== '' ? value : null; +} + +/** Channel types listed by the picker: text (0) and announcement (5); categories are type 4. */ +const TEXT = 0; +const ANNOUNCEMENT = 5; +const CATEGORY = 4; + +/** + * The Discord setup calls over the bot REST API. + * + * @returns The setup port. + */ +export function createDiscordSetup(options: DiscordSetupOptions = {}): DiscordSetup { + const base = (options.apiBase ?? DISCORD_API_BASE).replace(/\/+$/, ''); + const fetchFn = options.fetch ?? fetch; + const gateway = + options.gateway ?? + new DiscordGatewayHub({ + ...(options.fetch !== undefined && { fetch: options.fetch }), + ...(options.apiBase !== undefined && { apiBase: options.apiBase }), + }); + const call = (token: string, method: string, path: string, body?: unknown) => + callPlatform( + { + url: `${base}${path}`, + method, + headers: { + authorization: `Bot ${token}`, + ...(body !== undefined && { 'content-type': 'application/json' }), + }, + ...(body !== undefined && { body: JSON.stringify(body) }), + }, + { fetch: fetchFn, secrets: [token], platform: 'Discord', refine: refineDiscord }, + ); + + return { + async bot(token: string): Promise { + const me = obj((await call(token, 'GET', '/users/@me')).json); + const app = obj((await call(token, 'GET', '/applications/@me')).json); + const guilds = (await call(token, 'GET', '/users/@me/guilds')).json; + const botId = str(me?.['id']); + const applicationId = str(app?.['id']) ?? botId; + if (botId === null || applicationId === null) { + throw new ChannelSendError('rejected', 'Discord did not say who the bot is'); + } + return { + applicationId, + botId, + username: str(me?.['username']) ?? botId, + guilds: (Array.isArray(guilds) ? guilds : []) + .map(obj) + .filter((g): g is Json => g !== null && str(g['id']) !== null) + .map((g) => ({ id: String(g['id']), name: str(g['name']) ?? String(g['id']) })), + }; + }, + + async channels(token: string, guildId: string): Promise { + const answer = await call(token, 'GET', `/guilds/${encodeURIComponent(guildId)}/channels`); + const rows = (Array.isArray(answer.json) ? answer.json : []) + .map(obj) + .filter((c): c is Json => c !== null); + const categories = new Map(); + for (const c of rows) { + if (c['type'] === CATEGORY && str(c['id']) !== null) { + categories.set(String(c['id']), { + name: str(c['name']) ?? '', + position: Number(c['position'] ?? 0), + }); + } + } + return rows + .filter((c) => (c['type'] === TEXT || c['type'] === ANNOUNCEMENT) && str(c['id']) !== null) + .map((c) => { + const parent = str(c['parent_id']); + const category = parent === null ? null : (categories.get(parent) ?? null); + return { + info: { + id: String(c['id']), + name: str(c['name']) ?? String(c['id']), + type: c['type'] === ANNOUNCEMENT ? ('announcement' as const) : ('text' as const), + category: category?.name ?? null, + }, + order: [category === null ? -1 : category.position, Number(c['position'] ?? 0)], + }; + }) + .sort( + (a, b) => (a.order[0] ?? 0) - (b.order[0] ?? 0) || (a.order[1] ?? 0) - (b.order[1] ?? 0), + ) + .map((c) => c.info); + }, + + async claim(token, channelId, { signal, deadline }) { + const code = randomBytes(12).toString('base64url'); + const posted = obj( + ( + await call(token, 'POST', `/channels/${encodeURIComponent(channelId)}/messages`, { + content: + '**BrowserHive** · Press **This is me** to allow your Discord account to answer BrowserHive requests in this channel. The button works for 2 minutes.', + allowed_mentions: { parse: [] }, + components: [ + { + type: 1, + components: [ + { type: 2, style: 1, label: 'This is me', custom_id: `${CLAIM_PREFIX}${code}` }, + ], + }, + ], + }) + ).json, + ); + const messageId = str(posted?.['id']); + try { + return await gateway.waitForClaim(token, code, { signal, deadline }); + } finally { + if (messageId !== null) { + await call( + token, + 'DELETE', + `/channels/${encodeURIComponent(channelId)}/messages/${messageId}`, + ).catch(() => undefined); + } + } + }, + }; +} diff --git a/packages/core/src/infra/notifications/discord.ts b/packages/core/src/infra/notifications/discord.ts index b27d858..b26fb2e 100644 --- a/packages/core/src/infra/notifications/discord.ts +++ b/packages/core/src/infra/notifications/discord.ts @@ -1,4 +1,4 @@ -/** @module infra/notifications/discord — the Discord adapter, webhook mode (spec 03 §9.5, D-38, D-40): a pure renderer to one embed plus link buttons (bot mode's interactive buttons are drawn for the preview only), and the webhook transport (send with `?wait=true`, edit keeping the screenshot, delete). */ +/** @module infra/notifications/discord — the Discord adapter (spec 03 §9.5, D-38, D-40, D-41): a pure renderer to one embed plus action rows (link buttons; interactive act buttons in bot mode), the webhook transport (send with `?wait=true`, edit keeping the screenshot, delete) and the bot transport (the same through the bot REST API, presses over the gateway). */ import type { Block, Inline, NotificationMessage } from '@browserhive/contracts/notifications'; import { @@ -7,14 +7,17 @@ import { type ChannelRenderer, ChannelSendError, type ChannelSendResult, + type ChannelSetup, type LinkBuilder, type NotificationChannel, type NotificationImageReader, type PlatformMessageRef, + type PressSource, type RenderContext, type RenderedRequest, } from '../../ports/notification-channel.ts'; import type { NotificationChannelRecord } from '../../ports/persistence/records.ts'; +import { DISCORD_API_BASE, type DiscordGatewayHub } from './discord-gateway.ts'; import { callPlatform, type FailureRefiner, @@ -62,7 +65,7 @@ export const DISCORD_WEBHOOK_CAPABILITIES: ChannelCapabilities = { maxButtons: 5, }; -/** Bot mode (N2): interactive act buttons; drawn by the preview's "What's the difference?" panel. */ +/** Bot mode with act buttons on: interactive buttons, presses over the gateway (D-38, D-41). */ export const DISCORD_BOT_CAPABILITIES: ChannelCapabilities = { ...DISCORD_WEBHOOK_CAPABILITIES, actButtons: true, @@ -227,13 +230,15 @@ export function discordEmbed( type Button = | { type: 2; style: 5; label: string; url: string } - | { type: 2; style: 1 | 2 | 4; label: string; custom_id: string }; + | { type: 2; style: 1 | 2 | 3 | 4; label: string; custom_id: string }; -/** Action rows: link buttons, and in bot mode interactive act buttons. */ +/** + * Action rows: link buttons, and in bot mode interactive act buttons (an affirmative answer green, + * a destructive one red, others grey). Act actions survive `degrade` only where presses arrive. + */ export function discordComponents( message: NotificationMessage, links: LinkBuilder, - capabilities: ChannelCapabilities, context: RenderContext, ): { type: 1; components: Button[] }[] { const buttons: Button[] = []; @@ -241,8 +246,8 @@ export function discordComponents( const label = clipText(action.label, DISCORD_LIMITS.buttonLabel); if (action.kind === 'open') { if (!links.local) buttons.push({ type: 2, style: 5, label, url: links.url(action.path) }); - } else if (capabilities.actButtons) { - const style = action.style === 'primary' ? 1 : action.style === 'danger' ? 4 : 2; + } else if (context.mode === 'bot') { + const style = action.style === 'primary' ? 3 : action.style === 'danger' ? 4 : 2; buttons.push({ type: 2, style, label, custom_id: context.actToken(action.id) }); } } @@ -253,24 +258,41 @@ export function discordComponents( return rows; } -function capabilitiesOf(mode: string | null): ChannelCapabilities { - return mode === 'bot' ? DISCORD_BOT_CAPABILITIES : DISCORD_WEBHOOK_CAPABILITIES; +/** + * The capabilities of a Discord channel: act buttons in bot mode when its rules switch them on. + * + * @returns The capabilities. + */ +export function discordCapabilities( + setup: Pick, +): ChannelCapabilities { + return setup.mode === 'bot' && setup.rules.act_buttons === true + ? DISCORD_BOT_CAPABILITIES + : DISCORD_WEBHOOK_CAPABILITIES; +} + +/** Where a request goes: the webhook URL (`{secret:webhook}`), or the bot API channel path. */ +function messagesPath(context: RenderContext): string { + if (context.mode === 'bot') { + return `/channels/${context.target['channel_id'] ?? '{channel_id}'}/messages`; + } + return '{secret:webhook}'; } /** * The Discord renderer. Webhook sends go to `{secret:webhook}?wait=true&with_components=true` - * (the path placeholder is the secret webhook URL); a screenshot makes the request multipart - * (`payload_json` + `files[0]`, shown as the embed image). Edits `PATCH …/messages/{id}` list the - * attachment to keep. + * (the path placeholder is the secret webhook URL); bot sends to `/channels/{id}/messages`. A + * screenshot makes the request multipart (`payload_json` + `files[0]`, shown as the embed image). + * Edits `PATCH …/messages/{id}` list the attachment to keep. */ export const discordRenderer: ChannelRenderer = { kind: 'discord', - capabilities: capabilitiesOf, + capabilities: discordCapabilities, render(delivery: ChannelDelivery, context: RenderContext): readonly RenderedRequest[] { const { message, links } = delivery; - const capabilities = capabilitiesOf(context.mode); + const bot = context.mode === 'bot'; const image = firstImage(message); - const components = discordComponents(message, links, capabilities, context); + const components = discordComponents(message, links, context); const base = { content: null, allowed_mentions: { parse: [] as string[] }, @@ -279,7 +301,9 @@ export const discordRenderer: ChannelRenderer = { if (context.op === 'edit' && context.ref !== null) { const kept = context.ref['attachment_id']; const keptName = context.ref['attachment_name']; - const path = `{secret:webhook}/messages/${context.ref['message_id']}?with_components=true`; + const path = bot + ? `${messagesPath(context)}/${context.ref['message_id']}` + : `{secret:webhook}/messages/${context.ref['message_id']}?with_components=true`; if (image !== null && kept !== undefined) { const name = String(keptName ?? SCREENSHOT_FILENAME); return [ @@ -326,7 +350,7 @@ export const discordRenderer: ChannelRenderer = { }, ]; } - const path = '{secret:webhook}?wait=true&with_components=true'; + const path = bot ? messagesPath(context) : '{secret:webhook}?wait=true&with_components=true'; if (image !== null) { return [ { @@ -366,15 +390,52 @@ export const refineDiscord: FailureRefiner = (answer) => { : null; if (code === 10015) return new ChannelSendError('auth', 'Discord: the webhook no longer exists'); if (code === 10008) return new ChannelSendError('message_gone', `Discord: ${answer.detail}`); + if (code === 50035) { + // "Invalid Form Body": name the first field Discord refused (it never holds a secret). + const where = firstFormError( + answer.json !== null && typeof answer.json === 'object' + ? Reflect.get(answer.json, 'errors') + : null, + [], + ); + if (where !== null) { + return new ChannelSendError( + 'rejected', + `Discord ${answer.status}: ${answer.detail} (${where})`, + ); + } + } return null; }; +/** The first `path: message` of a Discord form-error tree (`{components: {0: {_errors: […]}}}`). */ +function firstFormError(node: unknown, path: readonly string[]): string | null { + if (node === null || typeof node !== 'object') return null; + const errors = Reflect.get(node, '_errors'); + if (Array.isArray(errors) && errors.length > 0) { + const message = Reflect.get(errors[0] as object, 'message'); + return `${path.join('.') || 'body'}: ${typeof message === 'string' ? message.slice(0, 120) : 'invalid'}`; + } + for (const [key, value] of Object.entries(node)) { + if (key === '_errors') continue; + const found = firstFormError(value, [...path, key]); + if (found !== null) return found; + } + return null; +} + /** What the Discord transport needs besides the channel row. */ export interface DiscordChannelDeps { - /** The webhook URL (resolved from the channel's `webhook` variable). */ - readonly webhookUrl: string; + /** Webhook mode: the webhook URL (resolved from the channel's `webhook` variable). */ + readonly webhookUrl?: string; + /** Bot mode: the bot token (resolved from the channel's `token` variable). */ + readonly botToken?: string; readonly images: NotificationImageReader; readonly fetch?: FetchFn; + /** Bot REST base; the fakes pass their own. */ + readonly apiBase?: string; + /** Bot mode: the gateway connections; the channel's presses arrive through them (D-41). */ + readonly gateway?: DiscordGatewayHub; } /** @@ -412,7 +473,8 @@ function refOf(answer: PlatformAnswer, previous: PlatformMessageRef | null): Pla } /** - * A Discord channel in webhook mode. Bot mode is refused (it arrives with act buttons, N2). + * A Discord channel. Webhook mode posts through the webhook URL; bot mode through the bot REST API + * (`Authorization: Bot …`) and, with act buttons on, listens for presses over the gateway (D-38). * * @returns The adapter. */ @@ -420,36 +482,57 @@ export function createDiscordChannel( record: NotificationChannelRecord, deps: DiscordChannelDeps, ): NotificationChannel { - if (record.mode === 'bot') { - throw new Error('Discord bot mode arrives with act buttons; use webhook mode'); - } - try { - const parsed = new URL(deps.webhookUrl); - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('scheme'); - } catch { - throw new Error(`the webhook variable of channel '${record.name}' does not hold a URL`); + const bot = record.mode === 'bot'; + let webhookUrl = ''; + let botToken = ''; + const apiBase = (deps.apiBase ?? DISCORD_API_BASE).replace(/\/+$/, ''); + if (bot) { + botToken = deps.botToken ?? ''; + if (botToken === '') throw new Error(`channel '${record.name}' has no bot token`); + if ((record.target['channel_id'] ?? '') === '') { + throw new Error(`channel '${record.name}' names no Discord channel`); + } + } else { + webhookUrl = deps.webhookUrl ?? ''; + try { + const parsed = new URL(webhookUrl); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('scheme'); + } catch { + throw new Error(`the webhook variable of channel '${record.name}' does not hold a URL`); + } } + const capabilities = discordCapabilities(record); const options = { fetch: deps.fetch ?? fetch, - secrets: [deps.webhookUrl, new URL(deps.webhookUrl).pathname], + secrets: bot ? [botToken] : [webhookUrl, new URL(webhookUrl).pathname], platform: 'Discord', refine: refineDiscord, }; - const context = (op: 'send' | 'edit', ref: PlatformMessageRef | null): RenderContext => ({ - mode: 'webhook', + const auth: Record = bot ? { authorization: `Bot ${botToken}` } : {}; + const context = ( + op: 'send' | 'edit', + ref: PlatformMessageRef | null, + delivery: ChannelDelivery, + ): RenderContext => ({ + mode: bot ? 'bot' : 'webhook', target: record.target, op, ref, - actToken: () => { - throw new ChannelSendError('rejected', 'act buttons need Discord bot mode'); + actToken: (id) => { + const payload = delivery.actTokens?.get(id); + if (payload === undefined) + throw new ChannelSendError('rejected', 'act button without a token'); + return payload; }, }); + const urlOf = (path: string) => + bot ? `${apiBase}${path}` : webhookUrlFor(webhookUrl, substituteSecrets(path, {})); async function perform( request: RenderedRequest, addressesMessage: boolean, ): Promise { - const url = webhookUrlFor(deps.webhookUrl, substituteSecrets(request.path, {})); + const url = urlOf(request.path); if (request.encoding === 'multipart') { const payload = request.body['payload_json'] as Record; const image = request.file === null ? null : await deps.images.read(request.file.ref); @@ -462,7 +545,7 @@ export function createDiscordChannel( { url, method: request.method, - headers: { 'content-type': 'application/json' }, + headers: { ...auth, 'content-type': 'application/json' }, body: JSON.stringify({ ...payload, embeds, attachments: [] }), addressesMessage, }, @@ -473,6 +556,7 @@ export function createDiscordChannel( { url, method: request.method, + headers: auth, body: multipart( { payload_json: payload }, { @@ -491,7 +575,7 @@ export function createDiscordChannel( { url, method: request.method, - headers: { 'content-type': 'application/json' }, + headers: { ...auth, 'content-type': 'application/json' }, body: JSON.stringify(request.body), addressesMessage, }, @@ -499,26 +583,36 @@ export function createDiscordChannel( ); } + const presses: PressSource | undefined = + bot && capabilities.actButtons && deps.gateway !== undefined + ? deps.gateway.pressSource(botToken, String(record.target['channel_id'])) + : undefined; + return { id: record.channelId, name: record.name, kind: 'discord', - capabilities: DISCORD_WEBHOOK_CAPABILITIES, + capabilities, + ...(presses !== undefined && { presses }), async send(delivery: ChannelDelivery): Promise { - const [request] = discordRenderer.render(delivery, context('send', null)); + const [request] = discordRenderer.render(delivery, context('send', null, delivery)); if (request === undefined) throw new ChannelSendError('rejected', 'nothing to send'); return { ref: refOf(await perform(request, false), null) }; }, async edit(ref: PlatformMessageRef, delivery: ChannelDelivery): Promise { - const [request] = discordRenderer.render(delivery, context('edit', ref)); + const [request] = discordRenderer.render(delivery, context('edit', ref, delivery)); if (request === undefined) return { ref }; return { ref: refOf(await perform(request, true), ref) }; }, async delete(ref: PlatformMessageRef): Promise { + const path = bot + ? `/channels/${record.target['channel_id']}/messages/${ref['message_id']}` + : `/messages/${ref['message_id']}`; await callPlatform( { - url: webhookUrlFor(deps.webhookUrl, `/messages/${ref['message_id']}`), + url: bot ? `${apiBase}${path}` : webhookUrlFor(webhookUrl, path), method: 'DELETE', + headers: auth, addressesMessage: true, }, options, diff --git a/packages/core/src/infra/notifications/index.ts b/packages/core/src/infra/notifications/index.ts index 13c82f4..cb589f4 100644 --- a/packages/core/src/infra/notifications/index.ts +++ b/packages/core/src/infra/notifications/index.ts @@ -1,5 +1,6 @@ -/** @module infra/notifications — the platform adapters of the notification channels (spec 03 §9.5, D-40): the renderers (shared with the preview), the transports as registry factories, the Telegram setup calls, the screenshot store and the `publicUrl` probe. The only code that calls a platform. */ +/** @module infra/notifications — the platform adapters of the notification channels (spec 03 §9.5, §9.6, D-40, D-41): the renderers (shared with the preview), the transports as registry factories, the press listeners (Telegram poller, Discord gateway, ntfy reply topic), the Telegram and Discord setup calls, the screenshot store and the `publicUrl` probe. The only code that calls a platform. */ +import type { Logger } from '../../ports/logger.ts'; import type { ChannelRenderer, NotificationChannel, @@ -7,9 +8,11 @@ import type { } from '../../ports/notification-channel.ts'; import type { NotificationChannelRecord } from '../../ports/persistence/records.ts'; import { createDiscordChannel, discordRenderer } from './discord.ts'; +import type { DiscordGatewayHub } from './discord-gateway.ts'; import type { FetchFn } from './http.ts'; import { createNtfyChannel, ntfyRenderer } from './ntfy.ts'; import { createTelegramChannel, telegramRenderer } from './telegram.ts'; +import type { CursorStore, TelegramUpdatesHub } from './telegram-updates.ts'; import { createWebhookChannel, webhookRenderer } from './webhook.ts'; export { @@ -18,8 +21,17 @@ export { DISCORD_LIMITS, DISCORD_WEBHOOK_CAPABILITIES, type DiscordChannelDeps, + discordCapabilities, discordRenderer, } from './discord.ts'; +export { + CLAIM_PREFIX, + DISCORD_API_BASE, + DiscordGatewayHub, + type DiscordGatewayOptions, + type GatewaySocket, +} from './discord-gateway.ts'; +export { createDiscordSetup, type DiscordSetupOptions } from './discord-setup.ts'; export { callPlatform, classifyFailure, type FetchFn, retryAfterMs, scrubDetail } from './http.ts'; export { createNotificationImageStore, @@ -30,21 +42,36 @@ export { createNtfyChannel, NTFY_CAPABILITIES, type NtfyChannelDeps, + ntfyCapabilities, ntfyRenderer, } from './ntfy.ts'; +export { createNtfyReplySource, type NtfyReplyOptions } from './ntfy-replies.ts'; export { LOCAL_LINKS_LABEL } from './render-common.ts'; export { createTelegramChannel, escapeHtml, + isRichRef, + RICH_PHOTO_ID, + richPhotoFileId, TELEGRAM_API_BASE, TELEGRAM_CAPABILITIES, TELEGRAM_CAPTION_MAX, + TELEGRAM_RICH_MAX, TELEGRAM_TEXT_MAX, type TelegramChannelDeps, telegramAcceptsUrl, + telegramCapabilities, + telegramClassicRenderer, telegramRenderer, + telegramRichHtml, } from './telegram.ts'; export { createTelegramSetup, type TelegramSetupOptions } from './telegram-setup.ts'; +export { + type CursorStore, + TELEGRAM_ALLOWED_UPDATES, + TelegramUpdatesHub, + type TelegramUpdatesOptions, +} from './telegram-updates.ts'; export { createUrlProbe, type UrlProbeOptions } from './url-probe.ts'; export { createWebhookChannel, @@ -53,6 +80,7 @@ export { TIMESTAMP_HEADER, WEBHOOK_CAPABILITIES, type WebhookChannelDeps, + webhookCapabilities, webhookRenderer, } from './webhook.ts'; @@ -80,7 +108,14 @@ export interface ChannelFactoriesDeps { readonly images: NotificationImageReader; readonly fetch?: FetchFn; /** Base URLs of the platforms (the fakes pass their own). */ - readonly apiBases?: { readonly telegram?: string }; + readonly apiBases?: { readonly telegram?: string; readonly discord?: string }; + /** The Telegram update pollers (act-button presses, D-41). */ + readonly telegramUpdates?: TelegramUpdatesHub; + /** The Discord gateway connections (bot mode, D-38). */ + readonly discordGateway?: DiscordGatewayHub; + /** Where the ntfy reply subscriptions resume. */ + readonly cursors?: CursorStore; + readonly logger?: Logger; } /** @@ -122,16 +157,29 @@ export function channelFactories( images: deps.images, ...(fetchFn !== undefined && { fetch: fetchFn }), ...(deps.apiBases?.telegram !== undefined && { apiBase: deps.apiBases.telegram }), + ...(deps.telegramUpdates !== undefined && { updates: deps.telegramUpdates }), + ...(deps.logger !== undefined && { logger: deps.logger }), }), ], [ 'discord', (channel, context) => - createDiscordChannel(channel, { - webhookUrl: secretOf(channel, context, 'webhook', true) ?? '', - images: deps.images, - ...(fetchFn !== undefined && { fetch: fetchFn }), - }), + createDiscordChannel( + channel, + channel.mode === 'bot' + ? { + botToken: secretOf(channel, context, 'token', true) ?? '', + images: deps.images, + ...(fetchFn !== undefined && { fetch: fetchFn }), + ...(deps.apiBases?.discord !== undefined && { apiBase: deps.apiBases.discord }), + ...(deps.discordGateway !== undefined && { gateway: deps.discordGateway }), + } + : { + webhookUrl: secretOf(channel, context, 'webhook', true) ?? '', + images: deps.images, + ...(fetchFn !== undefined && { fetch: fetchFn }), + }, + ), ], [ 'ntfy', @@ -139,8 +187,12 @@ export function channelFactories( createNtfyChannel(channel, { token: secretOf(channel, context, 'token', false), topic: secretOf(channel, context, 'topic', false), + replyTopic: secretOf(channel, context, 'reply_topic', false), + replyToken: secretOf(channel, context, 'reply_token', false), images: deps.images, ...(fetchFn !== undefined && { fetch: fetchFn }), + ...(deps.cursors !== undefined && { cursors: deps.cursors }), + ...(deps.logger !== undefined && { logger: deps.logger }), }), ], [ diff --git a/packages/core/src/infra/notifications/ntfy-replies.ts b/packages/core/src/infra/notifications/ntfy-replies.ts new file mode 100644 index 0000000..d697d2a --- /dev/null +++ b/packages/core/src/infra/notifications/ntfy-replies.ts @@ -0,0 +1,177 @@ +/** @module infra/notifications/ntfy-replies — the reply-topic subscription of an ntfy channel (spec 03 §9.6, D-42): a streaming `GET //json` (outbound), resumed after a restart or a dropped connection from the last message id it handled (`since=`), handing every `bh1:` message to the press handler. */ + +import { ACTION_PAYLOAD_RE } from '@browserhive/contracts/notifications'; +import { serializeError } from '../../kernel/errors/serialize-error.ts'; +import type { Logger } from '../../ports/logger.ts'; +import type { + ListenerStatus, + PressHandler, + PressSource, +} from '../../ports/notification-channel.ts'; +import { type FetchFn, scrubDetail } from './http.ts'; +import type { CursorStore } from './telegram-updates.ts'; + +/** Options of {@link createNtfyReplySource}. */ +export interface NtfyReplyOptions { + readonly server: string; + /** The reply topic (topic B). Never logged. */ + readonly topic: string; + /** Bearer token that reads it, or `null`. */ + readonly token: string | null; + /** `ntfy:`: never the topic, which may be a secret. */ + readonly cursorKey: string; + readonly cursors?: CursorStore; + readonly fetch?: FetchFn; + readonly logger?: Logger; + readonly now?: () => number; + /** Reconnect when the stream says nothing for this long (ntfy sends keepalives every 45 s). */ + readonly idleMs?: number; + /** First and longest wait between reconnects; default 1 s and 30 s. */ + readonly backoffMs?: { readonly min: number; readonly max: number }; +} + +interface NtfyEvent { + readonly id?: string; + readonly event?: string; + readonly message?: string; +} + +/** + * The press source of one ntfy channel. Only one subscription runs per channel; `listen` twice + * replaces the handler. + * + * @returns A {@link PressSource}. + */ +export function createNtfyReplySource(options: NtfyReplyOptions): PressSource { + const now = options.now ?? Date.now; + const fetchFn = options.fetch ?? fetch; + const idleMs = options.idleMs ?? 90_000; + const backoff = options.backoffMs ?? { min: 1_000, max: 30_000 }; + const secrets = [options.topic, ...(options.token === null ? [] : [options.token])]; + const log = options.logger?.child({ module: 'notifications' }); + let status: ListenerStatus = { state: 'connecting', since: now(), detail: null }; + let handler: PressHandler | null = null; + let notify: ((s: ListenerStatus) => void) | null = null; + let abort: AbortController | null = null; + + const setStatus = (state: ListenerStatus['state'], detail: string | null) => { + if (status.state === state && status.detail === detail) return; + status = { state, since: now(), detail }; + try { + notify?.(status); + } catch { + // a listener's callback never stops the subscription + } + }; + + const sleep = (ms: number, signal: AbortSignal) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener('abort', () => { + clearTimeout(timer); + resolve(); + }); + }); + + async function handle(line: string, signal: AbortSignal): Promise { + let event: NtfyEvent; + try { + event = JSON.parse(line) as NtfyEvent; + } catch { + return; + } + if (event.event !== 'message' || typeof event.id !== 'string') return; + const token = ACTION_PAYLOAD_RE.exec((event.message ?? '').trim())?.[1]; + if (token !== undefined && handler !== null && !signal.aborted) { + await handler({ token, origin: null, actor: { platform: 'ntfy', id: null, name: null } }); + } + await options.cursors?.set(options.cursorKey, event.id).catch(() => undefined); + } + + async function loop(signal: AbortSignal): Promise { + let delay = backoff.min; + const started = Math.floor(now() / 1000); + while (!signal.aborted) { + const cursor = await options.cursors?.get(options.cursorKey).catch(() => null); + const since = cursor ?? String(started); + const url = `${options.server}/${encodeURIComponent(options.topic)}/json?since=${encodeURIComponent(since)}`; + const idle = new AbortController(); + const both = AbortSignal.any([signal, idle.signal]); + let timer: ReturnType | undefined; + const arm = () => { + clearTimeout(timer); + timer = setTimeout(() => idle.abort(), idleMs); + }; + try { + arm(); + const response = await fetchFn(url, { + headers: { + 'user-agent': 'BrowserHive', + ...(options.token !== null && { authorization: `Bearer ${options.token}` }), + }, + signal: both, + }); + if (response.status === 401 || response.status === 403) { + setStatus('offline', 'ntfy refused access to the reply topic (check its token).'); + await response.body?.cancel().catch(() => undefined); + await sleep(5 * 60_000, signal); + continue; + } + if (!response.ok || response.body === null) { + throw new Error(`ntfy answered ${response.status}`); + } + setStatus('connected', null); + delay = backoff.min; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + arm(); + buffer += decoder.decode(value, { stream: true }); + let nl = buffer.indexOf('\n'); + while (nl >= 0) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (line !== '') await handle(line, signal); + nl = buffer.indexOf('\n'); + } + } + throw new Error('ntfy closed the stream'); + } catch (err) { + if (signal.aborted) break; + setStatus('reconnecting', 'The ntfy reply topic stream dropped; reconnecting.'); + log?.debug('ntfy reply stream', { + detail: scrubDetail(serializeError(err).message, secrets), + }); + await sleep(delay, signal); + delay = Math.min(backoff.max, delay * 2); + } finally { + clearTimeout(timer); + } + } + } + + return { + listen(next, onStatus) { + handler = next; + notify = onStatus; + if (abort === null) { + const controller = new AbortController(); + abort = controller; + setStatus('connecting', null); + void loop(controller.signal); + } + return () => { + if (handler === next) { + handler = null; + notify = null; + abort?.abort(); + abort = null; + } + }; + }, + status: () => status, + }; +} diff --git a/packages/core/src/infra/notifications/ntfy.ts b/packages/core/src/infra/notifications/ntfy.ts index f85499b..2fb67b2 100644 --- a/packages/core/src/infra/notifications/ntfy.ts +++ b/packages/core/src/infra/notifications/ntfy.ts @@ -1,21 +1,25 @@ -/** @module infra/notifications/ntfy — the ntfy adapter (spec 03 §9.5): a pure renderer to a JSON publish (or a `PUT` upload when a screenshot is attached) with priority, tags, click and `view` actions, and the transport (send, replace by sequence id, delete). */ +/** @module infra/notifications/ntfy — the ntfy adapter (spec 03 §9.5, D-42): a pure renderer to a JSON publish (or a `PUT` upload when a screenshot is attached) with priority, tags, click, `view` actions and — with a reply topic and act buttons on — `http` actions that post the command token to the reply topic; the transport (send, replace by sequence id, delete) and the reply-topic subscription. */ import type { Block, NotificationMessage } from '@browserhive/contracts/notifications'; import { NTFY_DEFAULT_SERVER } from '@browserhive/contracts/notifications'; +import type { Logger } from '../../ports/logger.ts'; import { type ChannelCapabilities, type ChannelDelivery, type ChannelRenderer, ChannelSendError, type ChannelSendResult, + type ChannelSetup, type NotificationChannel, type NotificationImageReader, type PlatformMessageRef, + type PressSource, type RenderContext, type RenderedRequest, } from '../../ports/notification-channel.ts'; import type { NotificationChannelRecord } from '../../ports/persistence/records.ts'; import { callPlatform, type FetchFn, type PlatformAnswer, substituteSecrets } from './http.ts'; +import { createNtfyReplySource } from './ntfy-replies.ts'; import { bodyBlocks, clipText, @@ -25,6 +29,7 @@ import { plainRun, SCREENSHOT_FILENAME, } from './render-common.ts'; +import type { CursorStore } from './telegram-updates.ts'; /** ntfy turns a message longer than 4096 bytes into an attachment; stay well below. */ export const NTFY_MESSAGE_MAX_BYTES = 4000; @@ -51,6 +56,22 @@ export const NTFY_CAPABILITIES: ChannelCapabilities = { maxButtons: NTFY_ACTIONS_MAX, }; +/** + * The capabilities of an ntfy channel: act buttons when its rules switch them on and it has a reply + * topic for the buttons to post to (D-42). + * + * @returns The capabilities. + */ +export function ntfyCapabilities( + setup: Pick, +): ChannelCapabilities { + const reply = + (setup.target['reply_topic'] ?? '') !== '' || setup.secretRefs['reply_topic'] !== undefined; + return reply && setup.rules.act_buttons === true + ? { ...NTFY_CAPABILITIES, actButtons: true } + : NTFY_CAPABILITIES; +} + /** ntfy priority: info 3, warn and error 4, critical 5; silent revisions 2 (no sound). */ export function ntfyPriority(message: Pick): number { if (!message.alert) return 2; @@ -144,6 +165,22 @@ interface ViewAction { clear: boolean; } +/** A button that makes the phone post the command token to the reply topic (D-42). */ +interface HttpAction { + action: 'http'; + label: string; + url: string; + method: 'POST'; + body: string; + clear: true; +} + +/** The reply topic as rendered: literal, or `{secret:reply_topic}` from a variable. */ +function replyTopicOf(target: Readonly>): string { + const literal = target['reply_topic']; + return literal !== undefined && literal !== '' ? literal : '{secret:reply_topic}'; +} + /** * The topic of a channel as rendered: the literal topic, or `{secret:topic}` when it lives in a * variable (the transport substitutes it; the preview shows the variable's name). @@ -161,7 +198,7 @@ function topicOf(target: Readonly>): string { */ export const ntfyRenderer: ChannelRenderer = { kind: 'ntfy', - capabilities: () => NTFY_CAPABILITIES, + capabilities: ntfyCapabilities, render(delivery: ChannelDelivery, context: RenderContext): readonly RenderedRequest[] { const { message, links } = delivery; const topic = topicOf(context.target); @@ -169,19 +206,40 @@ export const ntfyRenderer: ChannelRenderer = { context.op === 'edit' && context.ref !== null && context.ref['sequence_id'] !== undefined ? String(context.ref['sequence_id']) : message.id; - const resolved = openLinks(message, links).slice(0, NTFY_ACTIONS_MAX); - const actions: ViewAction[] = resolved.map((l, i) => ({ - action: 'view', - label: links.local && i === 0 ? LOCAL_LINKS_LABEL : clipText(l.label, 40), - url: l.url, - clear: false, - })); + const server = (context.target['server'] ?? NTFY_DEFAULT_SERVER).replace(/\/+$/, ''); + // Act buttons first (they answer), then links, three at most (D-42). Act actions survive + // `degrade` only where the channel has a reply topic and act buttons on. + const answers: HttpAction[] = message.actions.flatMap((a) => + a.kind === 'act' + ? [ + { + action: 'http' as const, + label: clipText(a.label, 40), + url: `${server}/${replyTopicOf(context.target)}`, + method: 'POST' as const, + body: context.actToken(a.id), + clear: true as const, + }, + ] + : [], + ); + const links3 = openLinks(message, links); + const resolved = links3.slice(0, Math.max(0, NTFY_ACTIONS_MAX - answers.length)); + const actions: (HttpAction | ViewAction)[] = [ + ...answers.slice(0, NTFY_ACTIONS_MAX), + ...resolved.map((l, i) => ({ + action: 'view' as const, + label: links.local && i === 0 ? LOCAL_LINKS_LABEL : clipText(l.label, 40), + url: l.url, + clear: false, + })), + ]; const fields = { title: clipText(message.title, NTFY_CAPABILITIES.maxTitleChars), message: ntfyText(message), priority: ntfyPriority(message), tags: ntfyTags(message), - ...(resolved[0] !== undefined && { click: resolved[0].url }), + ...(links3[0] !== undefined && { click: links3[0].url }), ...(actions.length > 0 && { actions }), }; const image = firstImage(message); @@ -234,8 +292,15 @@ export interface NtfyChannelDeps { readonly token: string | null; /** The topic from a variable (when `target.topic` is empty). */ readonly topic: string | null; + /** The reply topic from a variable (when `target.reply_topic` is empty). */ + readonly replyTopic?: string | null; + /** Access token that reads the reply topic (default: `token`). */ + readonly replyToken?: string | null; readonly images: NotificationImageReader; readonly fetch?: FetchFn; + /** Where the reply subscription resumes after a restart (`notification_cursors`). */ + readonly cursors?: CursorStore; + readonly logger?: Logger; } function refOf(answer: PlatformAnswer, sequence: string): PlatformMessageRef { @@ -262,27 +327,45 @@ export function createNtfyChannel( const topic = literal !== undefined && literal !== '' ? literal : deps.topic; if (topic === null || topic === '') throw new Error(`channel '${record.name}' has no ntfy topic`); const resolvedTopic: string = topic; + const replyLiteral = record.target['reply_topic']; + const replyTopic = + replyLiteral !== undefined && replyLiteral !== '' ? replyLiteral : (deps.replyTopic ?? null); + const replyToken = deps.replyToken ?? deps.token; const secrets = [ ...(deps.token === null ? [] : [deps.token]), ...(deps.topic === null ? [] : [deps.topic]), + ...(deps.replyTopic === null || deps.replyTopic === undefined ? [] : [deps.replyTopic]), + ...(deps.replyToken === null || deps.replyToken === undefined ? [] : [deps.replyToken]), ]; const options = { fetch: deps.fetch ?? fetch, secrets, platform: 'ntfy' }; const auth: Record = deps.token === null ? {} : { authorization: `Bearer ${deps.token}` }; - const context = (op: 'send' | 'edit', ref: PlatformMessageRef | null): RenderContext => ({ + const capabilities = ntfyCapabilities(record); + const values = { topic: resolvedTopic, ...(replyTopic !== null && { reply_topic: replyTopic }) }; + const context = ( + op: 'send' | 'edit', + ref: PlatformMessageRef | null, + delivery: ChannelDelivery, + ): RenderContext => ({ mode: null, target: record.target, op, ref, - actToken: () => { - throw new ChannelSendError('rejected', 'act buttons are not available on ntfy yet'); + actToken: (id) => { + const payload = delivery.actTokens?.get(id); + if (payload === undefined) + throw new ChannelSendError('rejected', 'act button without a token'); + return payload; }, }); + /** A rendered body with its `{secret:…}` placeholders (the reply topic in `http` actions) filled. */ + const filled = (body: Readonly>): Record => + JSON.parse(substituteSecrets(JSON.stringify(body), values)) as Record; /** Publishes the text of a binary request as JSON, keeping its sequence id (no screenshot). */ function publishText(path: string, request: RenderedRequest): Promise { const [, , sequence = ''] = path.split('/'); - const { filename: _dropped, ...fields } = request.body; + const { filename: _dropped, ...fields } = filled(request.body); return callPlatform( { url: `${server}/`, @@ -308,7 +391,7 @@ export function createNtfyChannel( try { return await callPlatform( { - url: `${server}${path}${ntfyQuery(request.body)}`, + url: `${server}${path}${ntfyQuery(filled(request.body))}`, method: 'PUT', headers: { ...auth, 'content-type': image.contentType }, body: new Blob([new Uint8Array(image.bytes)], { type: image.contentType }), @@ -328,7 +411,7 @@ export function createNtfyChannel( throw err; } } - const body = { ...request.body, topic: resolvedTopic }; + const body = { ...filled(request.body), topic: resolvedTopic }; return callPlatform( { url: `${server}${path}`, @@ -340,18 +423,32 @@ export function createNtfyChannel( ); } + const presses: PressSource | undefined = + capabilities.actButtons && replyTopic !== null + ? createNtfyReplySource({ + server, + topic: replyTopic, + token: replyToken, + cursorKey: `ntfy:${record.channelId}`, + ...(deps.cursors !== undefined && { cursors: deps.cursors }), + ...(deps.fetch !== undefined && { fetch: deps.fetch }), + ...(deps.logger !== undefined && { logger: deps.logger }), + }) + : undefined; + return { id: record.channelId, name: record.name, kind: 'ntfy', - capabilities: NTFY_CAPABILITIES, + capabilities, + ...(presses !== undefined && { presses }), async send(delivery: ChannelDelivery): Promise { - const [request] = ntfyRenderer.render(delivery, context('send', null)); + const [request] = ntfyRenderer.render(delivery, context('send', null, delivery)); if (request === undefined) throw new ChannelSendError('rejected', 'nothing to send'); return { ref: refOf(await publish(request), delivery.message.id) }; }, async edit(ref: PlatformMessageRef, delivery: ChannelDelivery): Promise { - const [request] = ntfyRenderer.render(delivery, context('edit', ref)); + const [request] = ntfyRenderer.render(delivery, context('edit', ref, delivery)); if (request === undefined) return { ref }; const sequence = String(ref['sequence_id'] ?? delivery.message.id); return { ref: refOf(await publish(request), sequence) }; diff --git a/packages/core/src/infra/notifications/telegram-setup.ts b/packages/core/src/infra/notifications/telegram-setup.ts index 2eab44c..3fb8eee 100644 --- a/packages/core/src/infra/notifications/telegram-setup.ts +++ b/packages/core/src/infra/notifications/telegram-setup.ts @@ -1,4 +1,4 @@ -/** @module infra/notifications/telegram-setup — the setup-only Telegram calls of the connect flow (spec 03 §4.8.1): the bot's username and the wait for `/start ` over `getUpdates` long polling. The persistent callback loop of act buttons is N2's. */ +/** @module infra/notifications/telegram-setup — the setup-only Telegram calls of the connect flow (spec 03 §4.8.1): the bot's username and the wait for `/start `, which goes through the bot's shared update poller (Telegram answers 409 to two concurrent `getUpdates`, D-41). */ import { ChannelSendError, @@ -7,9 +7,7 @@ import { } from '../../ports/notification-channel.ts'; import { callPlatform, type FetchFn } from './http.ts'; import { refineTelegram, TELEGRAM_API_BASE } from './telegram.ts'; - -/** Longest single `getUpdates` wait (seconds). */ -const LONG_POLL_S = 25; +import { TelegramUpdatesHub } from './telegram-updates.ts'; /** Options of {@link createTelegramSetup}. */ export interface TelegramSetupOptions { @@ -17,6 +15,8 @@ export interface TelegramSetupOptions { readonly apiBase?: string; /** Wall clock (epoch ms); injectable for tests. */ readonly now?: () => number; + /** The update pollers shared with the channels' act buttons (a private one otherwise). */ + readonly updates?: TelegramUpdatesHub; } function obj(value: unknown): Record | null { @@ -50,7 +50,13 @@ export function isStartCommand(text: string, code: string): boolean { export function createTelegramSetup(options: TelegramSetupOptions = {}): TelegramSetup { const base = (options.apiBase ?? TELEGRAM_API_BASE).replace(/\/+$/, ''); const fetchFn = options.fetch ?? fetch; - const now = options.now ?? Date.now; + const updates = + options.updates ?? + new TelegramUpdatesHub({ + ...(options.fetch !== undefined && { fetch: options.fetch }), + ...(options.apiBase !== undefined && { apiBase: options.apiBase }), + ...(options.now !== undefined && { now: options.now }), + }); const call = ( token: string, method: string, @@ -82,65 +88,36 @@ export function createTelegramSetup(options: TelegramSetupOptions = {}): Telegra }, async waitForStart(token, code, { signal, deadline }): Promise { - let offset: number | undefined; - while (!signal.aborted && now() < deadline) { - const wait = Math.max(0, Math.min(LONG_POLL_S, Math.floor((deadline - now()) / 1000))); - let answer: Awaited>; - try { - answer = await call( - token, - 'getUpdates', - { - ...(offset !== undefined && { offset }), - timeout: wait, - allowed_updates: ['message', 'my_chat_member'], - }, - (wait + 10) * 1000, - signal, - ); - } catch (err) { - if (signal.aborted) return null; - throw err; - } - if (signal.aborted) return null; - const updates = obj(answer.json)?.['result']; - if (!Array.isArray(updates)) continue; - for (const raw of updates) { - const update = obj(raw); - const id = update?.['update_id']; - if (typeof id === 'number') offset = id + 1; - const message = obj(update?.['message']); - const text = message?.['text']; - if (message === null || typeof text !== 'string' || !isStartCommand(text, code)) continue; - const chat = obj(message['chat']); - const from = obj(message['from']); - const chatId = chat?.['id']; - if (typeof chatId !== 'number' && typeof chatId !== 'string') continue; - const type = typeof chat?.['type'] === 'string' ? chat['type'] : 'private'; - const title = - typeof chat?.['title'] === 'string' ? chat['title'] : nameOf(chat) || String(chatId); - const thread = message['message_thread_id']; - // Acknowledge what was read, so the next connect does not see this /start again. - await call(token, 'getUpdates', { offset, timeout: 0 }).catch(() => undefined); - const userId = from?.['id']; - return { - chat: { - id: String(chatId), - title, - type, - threadId: - typeof thread === 'number' && message['is_topic_message'] === true - ? String(thread) - : null, - }, - user: - typeof userId === 'number' || typeof userId === 'string' - ? { id: String(userId), name: nameOf(from) || String(userId) } - : null, - }; - } - } - return null; + const message = await updates.waitForMessage( + token, + (m) => typeof m['text'] === 'string' && isStartCommand(m['text'], code), + { signal, deadline }, + ); + if (message === null) return null; + const chat = obj(message['chat']); + const from = obj(message['from']); + const chatId = chat?.['id']; + if (typeof chatId !== 'number' && typeof chatId !== 'string') return null; + const type = typeof chat?.['type'] === 'string' ? chat['type'] : 'private'; + const title = + typeof chat?.['title'] === 'string' ? chat['title'] : nameOf(chat) || String(chatId); + const thread = message['message_thread_id']; + const userId = from?.['id']; + return { + chat: { + id: String(chatId), + title, + type, + threadId: + typeof thread === 'number' && message['is_topic_message'] === true + ? String(thread) + : null, + }, + user: + typeof userId === 'number' || typeof userId === 'string' + ? { id: String(userId), name: nameOf(from) || String(userId) } + : null, + }; }, }; } diff --git a/packages/core/src/infra/notifications/telegram-updates.ts b/packages/core/src/infra/notifications/telegram-updates.ts new file mode 100644 index 0000000..0d112ed --- /dev/null +++ b/packages/core/src/infra/notifications/telegram-updates.ts @@ -0,0 +1,413 @@ +/** @module infra/notifications/telegram-updates — one `getUpdates` long-poll loop per Telegram bot token (spec 03 §9.6, D-41): act-button presses (`callback_query`) go to the channel of their chat, `/start` messages to the setup's waiters; the next offset is stored after each update, so a restart neither loses nor repeats a press. Outbound only (D-33). */ + +import { ACTION_PAYLOAD_RE } from '@browserhive/contracts/notifications'; +import { serializeError } from '../../kernel/errors/serialize-error.ts'; +import type { Logger } from '../../ports/logger.ts'; +import { + ChannelSendError, + type ListenerStatus, + type PressHandler, + type PressSource, +} from '../../ports/notification-channel.ts'; +import { callPlatform, type FetchFn } from './http.ts'; +import { refineTelegram, TELEGRAM_API_BASE } from './telegram.ts'; + +/** Where the pollers keep their offsets (`notification_cursors`). */ +export interface CursorStore { + get(key: string): Promise; + set(key: string, value: string): Promise; +} + +/** Options of {@link TelegramUpdatesHub}. */ +export interface TelegramUpdatesOptions { + readonly fetch?: FetchFn; + readonly apiBase?: string; + readonly cursors?: CursorStore; + readonly logger?: Logger; + /** Wall clock (epoch ms). */ + readonly now?: () => number; + /** Seconds one `getUpdates` waits (Telegram's long poll); default 25. */ + readonly pollSeconds?: number; + /** How long a poller outlives its last channel (a registry reload re-subscribes); default 5 s. */ + readonly lingerMs?: number; + /** First and longest wait between failed polls; default 1 s and 30 s. */ + readonly backoffMs?: { readonly min: number; readonly max: number }; + /** Wait after `auth` or a 409 (another poller, a webhook); default 5 min and 30 s. */ + readonly offlineRetryMs?: { readonly auth: number; readonly conflict: number }; +} + +/** The update types the pollers ask for (the setting persists on Telegram's side). */ +export const TELEGRAM_ALLOWED_UPDATES = ['message', 'callback_query', 'my_chat_member'] as const; + +type Json = Record; + +function obj(value: unknown): Json | null { + return value !== null && typeof value === 'object' ? (value as Json) : null; +} + +function nameOf(user: Json | null): string | null { + if (user === null) return null; + const first = typeof user['first_name'] === 'string' ? user['first_name'] : ''; + const last = typeof user['last_name'] === 'string' ? user['last_name'] : ''; + const full = `${first} ${last}`.trim(); + if (full !== '') return full; + return typeof user['username'] === 'string' ? `@${user['username']}` : null; +} + +interface Subscriber { + readonly chatId: string; + readonly handler: PressHandler; + readonly onStatus: (status: ListenerStatus) => void; +} + +interface Waiter { + match(message: Json): boolean; + resolve(message: Json | null): void; +} + +/** The token's public half: the bot id before the colon (never the secret). */ +function botIdOf(token: string): string { + const id = token.split(':')[0] ?? ''; + return /^\d+$/.test(id) ? id : 'unknown'; +} + +class BotPoller { + readonly subscribers = new Set(); + readonly waiters = new Set(); + private status: ListenerStatus; + private running = false; + private abort: AbortController | null = null; + private lingerTimer: ReturnType | null = null; + private readonly seen: string[] = []; + + constructor( + private readonly token: string, + private readonly hub: TelegramUpdatesHub, + private readonly opts: Required< + Omit + > & { + readonly fetch: FetchFn; + readonly cursors: CursorStore | null; + readonly logger: Logger | null; + }, + ) { + this.status = { state: 'connecting', since: opts.now(), detail: null }; + } + + current(): ListenerStatus { + return this.status; + } + + wake(): void { + if (this.lingerTimer !== null) { + clearTimeout(this.lingerTimer); + this.lingerTimer = null; + } + if (this.running) return; + this.running = true; + this.setStatus('connecting', null); + void this.loop().finally(() => { + this.running = false; + }); + } + + release(): void { + if (this.subscribers.size > 0 || this.waiters.size > 0) return; + if (this.lingerTimer !== null) return; + this.lingerTimer = setTimeout(() => { + this.lingerTimer = null; + if (this.subscribers.size > 0 || this.waiters.size > 0) return; + this.halt(); + }, this.opts.lingerMs); + } + + halt(): void { + if (this.lingerTimer !== null) clearTimeout(this.lingerTimer); + this.lingerTimer = null; + this.abort?.abort(); + this.running = false; + for (const waiter of this.waiters) waiter.resolve(null); + this.waiters.clear(); + this.hub.forget(this.token, this); + } + + private get key(): string { + return `telegram:${botIdOf(this.token)}`; + } + + private setStatus(state: ListenerStatus['state'], detail: string | null): void { + if (this.status.state === state && this.status.detail === detail) return; + this.status = { state, since: this.opts.now(), detail }; + for (const sub of this.subscribers) { + try { + sub.onStatus(this.status); + } catch { + // a listener's callback never stops the poller + } + } + } + + private wanted(): boolean { + return this.subscribers.size > 0 || this.waiters.size > 0; + } + + private async sleep(ms: number, signal: AbortSignal): Promise { + await new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener('abort', () => { + clearTimeout(timer); + resolve(); + }); + }); + } + + private async loop(): Promise { + const abort = new AbortController(); + this.abort = abort; + const stored = await this.opts.cursors?.get(this.key).catch(() => null); + let offset = stored !== null && stored !== undefined ? Number(stored) : undefined; + if (offset !== undefined && !Number.isSafeInteger(offset)) offset = undefined; + let delay = this.opts.backoffMs.min; + // The first poll does not wait, so the card shows "connected" as soon as Telegram answers. + let first = true; + while (!abort.signal.aborted && (this.wanted() || this.lingerTimer !== null)) { + let updates: unknown[]; + try { + const answer = await callPlatform( + { + url: `${this.opts.apiBase}/bot${this.token}/getUpdates`, + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...(offset !== undefined && { offset }), + timeout: first ? 0 : this.opts.pollSeconds, + allowed_updates: TELEGRAM_ALLOWED_UPDATES, + }), + timeoutMs: (this.opts.pollSeconds + 10) * 1000, + signal: abort.signal, + }, + { + fetch: this.opts.fetch, + secrets: [this.token], + platform: 'Telegram', + refine: refineTelegram, + }, + ); + const result = obj(answer.json)?.['result']; + updates = Array.isArray(result) ? result : []; + this.setStatus('connected', null); + first = false; + delay = this.opts.backoffMs.min; + } catch (err) { + if (abort.signal.aborted) break; + const code = err instanceof ChannelSendError ? err.code : 'unavailable'; + if (code === 'auth') { + this.setStatus('offline', 'Telegram refused the bot token.'); + await this.sleep(this.opts.offlineRetryMs.auth, abort.signal); + } else if (err instanceof ChannelSendError && /409|webhook/i.test(err.message)) { + this.setStatus( + 'offline', + /webhook/i.test(err.message) + ? 'This bot has a webhook set, so it cannot be polled; remove it with deleteWebhook.' + : 'Another program is polling this bot (Telegram allows one).', + ); + await this.sleep(this.opts.offlineRetryMs.conflict, abort.signal); + } else { + this.setStatus('reconnecting', 'Telegram could not be reached; retrying.'); + this.opts.logger?.warn('telegram poll failed', { code, err: serializeError(err) }); + await this.sleep(delay, abort.signal); + delay = Math.min(this.opts.backoffMs.max, delay * 2); + } + continue; + } + for (const raw of updates) { + const update = obj(raw); + const id = update?.['update_id']; + if (typeof id !== 'number') continue; + try { + await this.dispatch(update ?? {}); + } catch (err) { + this.opts.logger?.warn('telegram update failed', { err: serializeError(err) }); + } + offset = id + 1; + await this.opts.cursors?.set(this.key, String(offset)).catch(() => undefined); + } + } + if (this.abort === abort) this.abort = null; + } + + private async dispatch(update: Json): Promise { + const callback = obj(update['callback_query']); + if (callback !== null) { + await this.press(callback); + return; + } + const message = obj(update['message']); + if (message === null) return; + for (const waiter of this.waiters) { + if (waiter.match(message)) { + this.waiters.delete(waiter); + waiter.resolve(message); + } + } + } + + private async answer(id: string, text: string | null, alert: boolean): Promise { + await callPlatform( + { + url: `${this.opts.apiBase}/bot${this.token}/answerCallbackQuery`, + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + callback_query_id: id, + ...(text !== null && { text: text.slice(0, 200) }), + ...(alert && { show_alert: true }), + }), + }, + { fetch: this.opts.fetch, secrets: [this.token], platform: 'Telegram' }, + ).catch(() => undefined); // "query is too old": the press was made while BrowserHive was off + } + + private async press(callback: Json): Promise { + const id = typeof callback['id'] === 'string' ? callback['id'] : null; + if (id === null) return; + if (this.seen.includes(id)) return; + this.seen.push(id); + if (this.seen.length > 256) this.seen.shift(); + const data = typeof callback['data'] === 'string' ? callback['data'] : ''; + const token = ACTION_PAYLOAD_RE.exec(data)?.[1]; + if (token === undefined) { + await this.answer(id, null, false); + return; + } + const chat = obj(obj(callback['message'])?.['chat']); + const chatId = chat?.['id']; + const origin = typeof chatId === 'number' || typeof chatId === 'string' ? String(chatId) : null; + const subs = [...this.subscribers]; + const sub = subs.find((s) => s.chatId === origin) ?? subs[0]; + if (sub === undefined) { + await this.answer(id, 'Answering from the chat is switched off.', false); + return; + } + const from = obj(callback['from']); + const fromId = from?.['id']; + const result = await sub.handler({ + token, + origin, + actor: { + platform: 'telegram', + id: typeof fromId === 'number' || typeof fromId === 'string' ? String(fromId) : null, + name: nameOf(from), + }, + }); + await this.answer(id, result.text, result.refused && result.outcome === 'not_allowed'); + } +} + +/** + * The Telegram update pollers, one per bot token, shared by the bot's channels (act buttons) and the + * setup's `/start` wait: Telegram answers 409 to two concurrent `getUpdates` of one bot. + */ +export class TelegramUpdatesHub { + private readonly bots = new Map(); + private readonly opts: ConstructorParameters[2]; + + constructor(options: TelegramUpdatesOptions = {}) { + this.opts = { + fetch: options.fetch ?? fetch, + apiBase: (options.apiBase ?? TELEGRAM_API_BASE).replace(/\/+$/, ''), + cursors: options.cursors ?? null, + logger: options.logger?.child({ module: 'notifications' }) ?? null, + now: options.now ?? Date.now, + pollSeconds: options.pollSeconds ?? 25, + lingerMs: options.lingerMs ?? 5_000, + backoffMs: options.backoffMs ?? { min: 1_000, max: 30_000 }, + offlineRetryMs: options.offlineRetryMs ?? { auth: 5 * 60_000, conflict: 30_000 }, + }; + } + + private poller(token: string): BotPoller { + let bot = this.bots.get(token); + if (bot === undefined) { + bot = new BotPoller(token, this, this.opts); + this.bots.set(token, bot); + } + return bot; + } + + /** @internal Drops a stopped poller. */ + forget(token: string, bot: BotPoller): void { + if (this.bots.get(token) === bot) this.bots.delete(token); + } + + /** + * The press source of one channel: presses of buttons in `chatId` go to its handler. + * + * @returns A {@link PressSource}. + */ + pressSource(token: string, chatId: string): PressSource { + return { + listen: (handler, onStatus) => { + const bot = this.poller(token); + const sub: Subscriber = { chatId, handler, onStatus }; + bot.subscribers.add(sub); + bot.wake(); + return () => { + bot.subscribers.delete(sub); + bot.release(); + }; + }, + status: () => + this.bots.get(token)?.current() ?? { + state: 'connecting', + since: this.opts.now(), + detail: null, + }, + }; + } + + /** + * Waits for a message that `match` accepts (the setup's `/start `), through the bot's + * poller (started for the wait when no channel runs it). + * + * @returns The message, or `null` on abort or deadline. + */ + waitForMessage( + token: string, + match: (message: Record) => boolean, + options: { readonly signal: AbortSignal; readonly deadline: number }, + ): Promise | null> { + const bot = this.poller(token); + return new Promise((resolve) => { + let done = false; + const waiter: Waiter = { + match, + resolve: (message) => { + if (done) return; + done = true; + clearTimeout(timer); + bot.waiters.delete(waiter); + resolve(message); + bot.release(); + }, + }; + const timer = setTimeout( + () => waiter.resolve(null), + Math.max(0, options.deadline - this.opts.now()), + ); + options.signal.addEventListener('abort', () => waiter.resolve(null)); + if (options.signal.aborted) { + waiter.resolve(null); + return; + } + bot.waiters.add(waiter); + bot.wake(); + }); + } + + /** Stops every poller (shutdown). */ + stop(): void { + for (const bot of [...this.bots.values()]) bot.halt(); + this.bots.clear(); + } +} diff --git a/packages/core/src/infra/notifications/telegram.ts b/packages/core/src/infra/notifications/telegram.ts index bee0482..077e148 100644 --- a/packages/core/src/infra/notifications/telegram.ts +++ b/packages/core/src/infra/notifications/telegram.ts @@ -1,17 +1,25 @@ -/** @module infra/notifications/telegram — the Telegram Bot API adapter (spec 03 §9.5, D-40): a pure renderer to classic `sendMessage`/`sendPhoto` with `parse_mode: HTML` and an inline keyboard, plus the transport (send, edit text or caption, delete within 48 h). */ +/** @module infra/notifications/telegram — the Telegram Bot API adapter (spec 03 §9.5, D-40, D-41): a pure renderer to Rich Messages (`sendRichMessage` / `editMessageText` with `rich_message`) with an inline keyboard of links and act buttons, the classic `sendMessage`/`sendPhoto` HTML renderer kept as the fallback, and the transport (send with fallback, edit in the message's own format, delete within 48 h, presses through the bot's update poller). */ -import type { Block, Inline, NotificationMessage } from '@browserhive/contracts/notifications'; +import type { + Block, + Inline, + NotificationAction, + NotificationMessage, +} from '@browserhive/contracts/notifications'; import { TELEGRAM_DELETE_WINDOW_MS } from '@browserhive/contracts/notifications'; +import type { Logger } from '../../ports/logger.ts'; import { type ChannelCapabilities, type ChannelDelivery, type ChannelRenderer, ChannelSendError, type ChannelSendResult, + type ChannelSetup, type LinkBuilder, type NotificationChannel, type NotificationImageReader, type PlatformMessageRef, + type PressSource, type RenderContext, type RenderedRequest, } from '../../ports/notification-channel.ts'; @@ -29,6 +37,7 @@ import { firstImage, LOCAL_LINKS_LABEL, openLinks, + plainRun, SCREENSHOT_FILENAME, severityMark, utcTime, @@ -43,15 +52,21 @@ export const TELEGRAM_CAPTION_MAX = 1024; /** Buttons per keyboard row: two keep labels like "Open in BrowserHive" readable on a phone. */ const BUTTONS_PER_ROW = 2; +/** Visible characters a Rich Message may hold (Bot API 10.1). */ +export const TELEGRAM_RICH_MAX = 32_768; +/** The media id of the screenshot inside a Rich Message (`tg://photo?id=shot`). */ +export const RICH_PHOTO_ID = 'shot'; + /** - * What the Telegram renderer supports. Rich blocks render natively (bold headings and labels, - * expandable quotes, `
`); tables become lists through `degrade`. The text budget leaves
- * room for the title line and the keyboard-less link section; a caption is clipped to 1024 by the
- * renderer itself.
+ * What the Telegram renderer supports. Rich Messages draw headings, tables (bordered), fields
+ * (compact tables), expandable quotes, code and footers natively (D-40); the classic fallback
+ * writes tables as lines. The text budget keeps messages readable on a phone; a classic caption is
+ * clipped to 1024 by the renderer itself. Act buttons depend on the channel's rules
+ * ({@link telegramCapabilities}).
  */
 export const TELEGRAM_CAPABILITIES: ChannelCapabilities = {
   richBlocks: true,
-  tables: false,
+  tables: true,
   images: true,
   actButtons: false,
   openLinks: true,
@@ -64,6 +79,18 @@ export const TELEGRAM_CAPABILITIES: ChannelCapabilities = {
   maxButtons: 6,
 };
 
+/**
+ * The capabilities of a Telegram channel: act buttons when its rules switch them on (presses
+ * arrive through the bot's update poller, D-41).
+ *
+ * @returns The capabilities.
+ */
+export function telegramCapabilities(setup: Pick): ChannelCapabilities {
+  return setup.rules.act_buttons === true
+    ? { ...TELEGRAM_CAPABILITIES, actButtons: true }
+    : TELEGRAM_CAPABILITIES;
+}
+
 /** Escapes text for Telegram HTML: only `<`, `>` and `&` (spec 03 §9.5). */
 export function escapeHtml(text: string): string {
   return text.replace(/&/g, '&').replace(//g, '>');
@@ -196,9 +223,19 @@ function renderBlock(block: Block, links: LinkBuilder, budget: number): Frag {
       });
       return lines(out);
     }
-    case 'table':
-      // `degrade` turns tables into lists for this renderer (tables: false).
-      return EMPTY;
+    case 'table': {
+      // Rich Messages draw tables; the classic fallback writes one line per row.
+      const out: Frag[] = [];
+      let left = budget;
+      for (const row of block.rows) {
+        const cells = row.map((cell, i) => `${block.columns[i] ?? ''}: ${plainRun(cell)}`);
+        const line = plain(`• ${cells.join(' · ')}`, left);
+        if (line.visible === 0) break;
+        out.push(line);
+        left -= line.visible + 1;
+      }
+      return lines(out);
+    }
     case 'code': {
       const inner = plain(block.text, budget);
       if (block.language !== null && /^[A-Za-z0-9_+-]{1,32}$/.test(block.language)) {
@@ -284,22 +321,49 @@ function localLinks(message: NotificationMessage, links: LinkBuilder): Frag {
   ]);
 }
 
-/** The inline keyboard of a message (URL buttons; callback buttons where act buttons are on). */
+/** One inline keyboard button. */
+interface KeyboardButton {
+  text: string;
+  url?: string;
+  callback_data?: string;
+  style?: 'success' | 'danger' | 'primary';
+}
+
+/**
+ * The colour of a button: an act button's affirmative answer is green (`success`), a destructive
+ * one red; a primary link is blue. Others keep the app's default.
+ */
+function buttonStyle(action: NotificationAction): KeyboardButton['style'] | undefined {
+  if (action.style === 'danger') return 'danger';
+  if (action.style === 'primary') return action.kind === 'act' ? 'success' : 'primary';
+  return undefined;
+}
+
+/**
+ * The inline keyboard of a message: URL buttons, and a callback button for every act action (they
+ * survive `degrade` only where the channel receives presses).
+ */
 function keyboard(
   message: NotificationMessage,
   links: LinkBuilder,
-  capabilities: ChannelCapabilities,
   context: RenderContext,
-): { inline_keyboard: { text: string; url?: string; callback_data?: string }[][] } {
-  const buttons: { text: string; url?: string; callback_data?: string }[] = [];
+): { inline_keyboard: KeyboardButton[][] } {
+  const buttons: KeyboardButton[] = [];
   for (const action of message.actions) {
+    const style = buttonStyle(action);
     if (action.kind === 'open') {
-      if (!linksAsText(links)) buttons.push({ text: action.label, url: links.url(action.path) });
-    } else if (capabilities.actButtons) {
-      buttons.push({ text: action.label, callback_data: context.actToken(action.id) });
+      if (!linksAsText(links)) {
+        buttons.push({ text: action.label, url: links.url(action.path), ...(style && { style }) });
+      }
+    } else {
+      buttons.push({
+        text: action.label,
+        callback_data: context.actToken(action.id),
+        ...(style && { style }),
+      });
     }
   }
-  const rows: { text: string; url?: string; callback_data?: string }[][] = [];
+  const rows: KeyboardButton[][] = [];
   for (let i = 0; i < buttons.length; i += BUTTONS_PER_ROW) {
     rows.push(buttons.slice(i, i + BUTTONS_PER_ROW));
   }
@@ -310,21 +374,26 @@ function isPhotoRef(ref: PlatformMessageRef | null): boolean {
   return ref !== null && (ref['photo'] === 1 || ref['photo'] === '1');
 }
 
+/** Whether a message ref was sent as a Rich Message (messages from before N2 were not). */
+export function isRichRef(ref: PlatformMessageRef | null): boolean {
+  return ref !== null && (ref['rich'] === 1 || ref['rich'] === '1');
+}
+
 /**
- * The Telegram renderer: one request per send or edit.
+ * The classic Telegram renderer (the fallback, D-40): one request per send or edit.
  * - send: `sendPhoto` (multipart, caption ≤ 1024) when the message carries a screenshot, else
  *   `sendMessage` (≤ 4096);
  * - edit: `editMessageCaption` for a photo message, else `editMessageText`, always with the
  *   keyboard (an empty one removes the buttons).
  */
-export const telegramRenderer: ChannelRenderer = {
+export const telegramClassicRenderer: ChannelRenderer = {
   kind: 'telegram',
-  capabilities: () => TELEGRAM_CAPABILITIES,
+  capabilities: telegramCapabilities,
   render(delivery: ChannelDelivery, context: RenderContext): readonly RenderedRequest[] {
     const { message, links } = delivery;
     const chat = context.target['chat_id'] ?? '';
     const thread = context.target['thread_id'];
-    const markup = keyboard(message, links, TELEGRAM_CAPABILITIES, context);
+    const markup = keyboard(message, links, context);
     if (context.op === 'edit' && context.ref !== null) {
       const photo = isPhotoRef(context.ref);
       const html = telegramHtml(message, links, photo ? TELEGRAM_CAPTION_MAX : TELEGRAM_TEXT_MAX);
@@ -395,6 +464,219 @@ export const telegramRenderer: ChannelRenderer = {
   },
 };
 
+// ---------------------------------------------------------------------------------------------
+// Rich Messages (D-40)
+// ---------------------------------------------------------------------------------------------
+
+/** Rich HTML text: escaped, newlines kept as `
`. */ +function richText(text: string): string { + return escapeHtml(text).replace(/\r?\n/g, '
'); +} + +/** One inline node as Rich HTML. */ +function richNode(node: Inline, links: LinkBuilder): string { + switch (node.type) { + case 'text': + return richText(node.text); + case 'bold': + return `${richText(node.text)}`; + case 'italic': + return `${richText(node.text)}`; + case 'code': + return `${escapeHtml(node.text)}`; + case 'link': + return linksAsText(links) + ? richText(node.text) + : `${richText(node.text)}`; + case 'time': { + const format = node.style === 'relative' ? 'r' : 't'; + return `${escapeHtml(utcTime(node.at))}`; + } + } +} + +/** One inline run as Rich HTML. */ +function richInline(run: readonly Inline[], links: LinkBuilder): string { + return run.map((node) => richNode(node, links)).join(''); +} + +/** One block as Rich HTML (`''` when it has nothing to show). */ +function richBlock(block: Block, links: LinkBuilder): string { + switch (block.type) { + case 'text': { + const inner = richInline(block.content, links); + return inner === '' ? '' : `

${inner}

`; + } + case 'heading': + return block.text.trim() === '' ? '' : `

${richText(block.text)}

`; + case 'fields': + return `${block.items + .map( + (item) => + ``, + ) + .join('')}
${richText(item.label)}${richInline(item.value, links) || '—'}
`; + case 'quote': { + const inner = richInline(block.content, links); + if (inner === '') return ''; + return block.collapsible + ? `
${inner}
` + : `
${inner}
`; + } + case 'list': { + const tag = block.ordered ? 'ol' : 'ul'; + return `<${tag}>${block.items.map((item) => `
  • ${richInline(item, links)}
  • `).join('')}`; + } + case 'table': { + const head = `${block.columns.map((c) => `${richText(c)}`).join('')}`; + const rows = block.rows + .map( + (row) => `${row.map((cell) => `${richInline(cell, links)}`).join('')}`, + ) + .join(''); + return `${head}${rows}
    `; + } + case 'code': { + const code = escapeHtml(block.text); + if (block.language !== null && /^[A-Za-z0-9_+-]{1,32}$/.test(block.language)) { + return `
    ${code}
    `; + } + return `
    ${code}
    `; + } + case 'divider': + return '
    '; + case 'footer': { + const inner = richInline(block.content, links); + return inner === '' ? '' : `
    ${inner}
    `; + } + case 'image': + return ''; + } +} + +/** + * The Rich HTML of a message (D-40): the title as a heading with its severity or outcome mark, + * the summary, the screenshot (a media block named {@link RICH_PHOTO_ID}), the blocks, and the + * links as text when they only open on this computer. + * + * @returns The `rich_message.html` value. + */ +export function telegramRichHtml( + message: NotificationMessage, + links: LinkBuilder, + withImage: boolean, +): string { + const parts: string[] = [`

    ${richText(`${severityMark(message)} ${message.title}`)}

    `]; + const summary = message.summary.trim(); + if (summary !== '' && summary !== message.title) parts.push(`

    ${richText(summary)}

    `); + if (withImage) parts.push(``); + for (const block of bodyBlocks(message)) { + const html = richBlock(block, links); + if (html !== '') parts.push(html); + } + if (linksAsText(links)) { + const resolved = openLinks(message, links); + if (resolved.length > 0) { + const title = links.local ? `🖥 ${LOCAL_LINKS_LABEL}` : '🔗 Links'; + parts.push( + `

    ${richText(title)}
    ${resolved + .map((l) => `${richText(l.label)}: ${escapeHtml(l.url)}`) + .join('
    ')}

    `, + ); + } + } + let html = parts.join(''); + // The text budget of `degrade` keeps messages far below the limit; this is the last guard. + while (html.length > TELEGRAM_RICH_MAX - 64 && parts.length > 2) { + parts.splice(parts.length - 2, 1); + html = parts.join(''); + } + return html; +} + +/** + * The Telegram renderer (D-40): one Rich Message request per send or edit. + * - send: `sendRichMessage` (JSON; multipart with the screenshot as `attach://shot` when the + * message carries one); + * - edit: `editMessageText` with `rich_message`, the screenshot re-used by the `file_id` stored in + * the ref (or uploaded again), and the keyboard (an empty one removes the buttons). A message + * sent classic (its ref has no `rich: 1`) is edited by the classic renderer. + */ +export const telegramRenderer: ChannelRenderer = { + kind: 'telegram', + capabilities: telegramCapabilities, + render(delivery: ChannelDelivery, context: RenderContext): readonly RenderedRequest[] { + if (context.op === 'edit' && context.ref !== null && !isRichRef(context.ref)) { + return telegramClassicRenderer.render(delivery, context); + } + const { message, links } = delivery; + const chat = context.target['chat_id'] ?? ''; + const thread = context.target['thread_id']; + const markup = keyboard(message, links, context); + const image = firstImage(message); + const html = telegramRichHtml(message, links, image !== null); + const fileId = + context.ref !== null && typeof context.ref['photo_file_id'] === 'string' + ? context.ref['photo_file_id'] + : null; + const upload = image !== null && (context.op === 'send' || fileId === null); + const media = + image === null + ? undefined + : [ + { + id: RICH_PHOTO_ID, + media: { type: 'photo', media: upload ? `attach://${RICH_PHOTO_ID}` : fileId }, + }, + ]; + const richMessage = { html, ...(media && { media }), skip_entity_detection: true }; + const file = + upload && image !== null + ? { ref: image.ref, name: SCREENSHOT_FILENAME, content_type: 'image/jpeg' } + : null; + if (context.op === 'edit' && context.ref !== null) { + return [ + { + method: 'POST', + path: 'editMessageText', + encoding: file === null ? 'json' : 'multipart', + body: { + chat_id: context.ref['chat_id'] ?? chat, + message_id: context.ref['message_id'], + rich_message: richMessage, + reply_markup: markup, + }, + headers: {}, + file, + }, + ]; + } + return [ + { + method: 'POST', + path: 'sendRichMessage', + encoding: file === null ? 'json' : 'multipart', + body: { + chat_id: chat, + ...(thread !== undefined && thread !== '' && { message_thread_id: Number(thread) }), + rich_message: richMessage, + disable_notification: !message.alert, + ...(markup.inline_keyboard.length > 0 && { reply_markup: markup }), + ...(delivery.replyTo !== null && + delivery.replyTo['message_id'] !== undefined && { + reply_parameters: { + message_id: Number(delivery.replyTo['message_id']), + allow_sending_without_reply: true, + }, + }), + }, + headers: {}, + file, + }, + ]; + }, +}; + /** * Telegram's 400 descriptions: a vanished message, one too old to delete, an unchanged edit * (harmless), a bot removed from the chat (auth), and a bot with a webhook set (rejected with a @@ -437,6 +719,12 @@ export interface TelegramChannelDeps { readonly fetch?: FetchFn; /** Bot API base; the fakes pass their own. */ readonly apiBase?: string; + /** + * The bot's update pollers (`TelegramUpdatesHub`): the channel's presses arrive through them (act + * buttons, D-41). + */ + readonly updates?: { pressSource(token: string, chatId: string): PressSource }; + readonly logger?: Logger; } function messageOf(answer: PlatformAnswer): Record | null { @@ -448,8 +736,39 @@ function messageOf(answer: PlatformAnswer): Record | null { } /** - * A Telegram channel: sends, edits (text or caption) and deletes through the Bot API. A missing - * screenshot (pruned) degrades to a text message instead of failing. + * The `file_id` of the largest photo a sent Rich Message carries (`rich_message.blocks[].photo`), + * so an edit can show the screenshot again without uploading it. + * + * @returns The file id, or `null`. + */ +export function richPhotoFileId(sent: Record | null): string | null { + const blocks = (sent?.['rich_message'] as { blocks?: unknown } | undefined)?.blocks; + const stack: unknown[] = Array.isArray(blocks) ? [...blocks] : []; + while (stack.length > 0) { + const node = stack.shift(); + if (node === null || typeof node !== 'object') continue; + const photo = (node as Record)['photo']; + if (Array.isArray(photo) && photo.length > 0) { + const largest = photo[photo.length - 1] as Record | undefined; + if (typeof largest?.['file_id'] === 'string') return largest['file_id']; + } + for (const value of Object.values(node as Record)) { + if (value !== null && typeof value === 'object') stack.push(value); + } + } + return null; +} + +/** A rich call Telegram refused as such (a 400 on the content, or a server without the method). */ +function richRefused(err: unknown): boolean { + return err instanceof ChannelSendError && err.code === 'rejected'; +} + +/** + * A Telegram channel: sends Rich Messages (falling back to classic HTML when Telegram refuses one, + * D-40), edits each message in the format it was sent in, deletes within 48 h, and listens for its + * act buttons through the bot's update poller when its rules switch them on (D-41). A missing + * screenshot (pruned) degrades to a message without it instead of failing. * * @returns The adapter. */ @@ -465,17 +784,40 @@ export function createTelegramChannel( platform: 'Telegram', refine: refineTelegram, }; + const capabilities = telegramCapabilities(record); const url = (method: string) => `${base}/bot${deps.token}/${method}`; - const context = (op: 'send' | 'edit', ref: PlatformMessageRef | null): RenderContext => ({ + // A Bot API server that does not know `sendRichMessage` (404) keeps this channel classic. + let classicOnly = false; + const context = ( + op: 'send' | 'edit', + ref: PlatformMessageRef | null, + delivery: ChannelDelivery, + ): RenderContext => ({ mode: record.mode, target: record.target, op, ref, - actToken: () => { - throw new ChannelSendError('rejected', 'act buttons are not available on Telegram yet'); + actToken: (id) => { + const payload = delivery.actTokens?.get(id); + if (payload === undefined) + throw new ChannelSendError('rejected', 'act button without a token'); + return payload; }, }); + /** The delivery without its screenshot when the image is gone (pruned). */ + async function present(delivery: ChannelDelivery): Promise { + const image = firstImage(delivery.message); + if (image === null || (await deps.images.read(image.ref)) !== null) return delivery; + return { + ...delivery, + message: { + ...delivery.message, + blocks: delivery.message.blocks.filter((b) => b.type !== 'image'), + }, + }; + } + async function perform( request: RenderedRequest, addressesMessage: boolean, @@ -488,7 +830,7 @@ export function createTelegramChannel( url: url(request.path), method: request.method, body: multipart(request.body, { - field: 'photo', + field: request.path === 'sendPhoto' ? 'photo' : RICH_PHOTO_ID, bytes: image.bytes, name: request.file.name, type: image.contentType, @@ -498,21 +840,23 @@ export function createTelegramChannel( options, ); } - const { caption, ...rest } = request.body; - return callPlatform( - { - url: url('sendMessage'), - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - ...rest, - text: caption, - link_preview_options: { is_disabled: true }, - }), - addressesMessage, - }, - options, - ); + if (request.path === 'sendPhoto') { + const { caption, ...rest } = request.body; + return callPlatform( + { + url: url('sendMessage'), + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...rest, + text: caption, + link_preview_options: { is_disabled: true }, + }), + addressesMessage, + }, + options, + ); + } } return callPlatform( { @@ -526,36 +870,89 @@ export function createTelegramChannel( ); } + function sentRef(answer: PlatformAnswer, rich: boolean, image: boolean): PlatformMessageRef { + const sent = messageOf(answer); + const chat = sent !== null ? Reflect.get(sent, 'chat') : null; + const chatId = chat !== null && typeof chat === 'object' ? Reflect.get(chat, 'id') : undefined; + const messageId = sent?.['message_id']; + if (typeof messageId !== 'number') { + throw new ChannelSendError('rejected', 'Telegram answered without a message id'); + } + const fileId = rich && image ? richPhotoFileId(sent) : null; + return { + chat_id: + typeof chatId === 'number' || typeof chatId === 'string' + ? chatId + : String(record.target['chat_id'] ?? ''), + message_id: messageId, + photo: rich ? (image ? 1 : 0) : Array.isArray(sent?.['photo']) ? 1 : 0, + rich: rich ? 1 : 0, + ...(fileId !== null && { photo_file_id: fileId }), + }; + } + + function fallback(err: unknown, op: 'send' | 'edit'): void { + if (err instanceof ChannelSendError && op === 'send' && /Telegram 404/.test(err.message)) { + classicOnly = true; + } + deps.logger?.warn('rich message refused', { + channel: record.name, + op, + code: err instanceof ChannelSendError ? err.code : 'unavailable', + }); + } + + const presses: PressSource | undefined = + capabilities.actButtons && deps.updates !== undefined + ? deps.updates.pressSource(deps.token, String(record.target['chat_id'] ?? '')) + : undefined; + return { id: record.channelId, name: record.name, kind: 'telegram', - capabilities: TELEGRAM_CAPABILITIES, - async send(delivery: ChannelDelivery): Promise { - const [request] = telegramRenderer.render(delivery, context('send', null)); - if (request === undefined) throw new ChannelSendError('rejected', 'nothing to send'); - const answer = await perform(request, false); - const sent = messageOf(answer); - const chat = sent !== null ? Reflect.get(sent, 'chat') : null; - const chatId = - chat !== null && typeof chat === 'object' ? Reflect.get(chat, 'id') : undefined; - const messageId = sent?.['message_id']; - if (typeof messageId !== 'number') { - throw new ChannelSendError('rejected', 'Telegram answered without a message id'); + capabilities, + ...(presses !== undefined && { presses }), + async send(input: ChannelDelivery): Promise { + const delivery = await present(input); + const image = firstImage(delivery.message) !== null; + if (!classicOnly) { + const [request] = telegramRenderer.render(delivery, context('send', null, delivery)); + if (request === undefined) throw new ChannelSendError('rejected', 'nothing to send'); + try { + return { ref: sentRef(await perform(request, false), true, image) }; + } catch (err) { + if (!richRefused(err)) throw err; + fallback(err, 'send'); + } } - return { - ref: { - chat_id: - typeof chatId === 'number' || typeof chatId === 'string' - ? chatId - : String(record.target['chat_id'] ?? ''), - message_id: messageId, - photo: Array.isArray(sent?.['photo']) ? 1 : 0, - }, - }; + const [request] = telegramClassicRenderer.render(delivery, context('send', null, delivery)); + if (request === undefined) throw new ChannelSendError('rejected', 'nothing to send'); + return { ref: sentRef(await perform(request, false), false, image) }; }, - async edit(ref: PlatformMessageRef, delivery: ChannelDelivery): Promise { - const [request] = telegramRenderer.render(delivery, context('edit', ref)); + async edit(ref: PlatformMessageRef, input: ChannelDelivery): Promise { + const delivery = await present(input); + if (isRichRef(ref)) { + const [request] = telegramRenderer.render(delivery, context('edit', ref, delivery)); + if (request === undefined) return { ref }; + try { + const answer = await perform(request, true); + const fileId = request.file === null ? null : richPhotoFileId(messageOf(answer)); + return { ref: fileId === null ? ref : { ...ref, photo_file_id: fileId } }; + } catch (err) { + if (!richRefused(err)) throw err; + fallback(err, 'edit'); + } + // The rich message becomes a classic text message (its screenshot is dropped). + const classicRef = { ...ref, photo: 0, rich: 0 }; + const [classic] = telegramClassicRenderer.render( + delivery, + context('edit', classicRef, delivery), + ); + if (classic !== undefined) await perform(classic, true); + return { ref: classicRef }; + } + const [request] = telegramClassicRenderer.render(delivery, context('edit', ref, delivery)); if (request === undefined) return { ref }; await perform(request, true); return { ref }; diff --git a/packages/core/src/infra/notifications/webhook.ts b/packages/core/src/infra/notifications/webhook.ts index 1c7a93a..b1482ef 100644 --- a/packages/core/src/infra/notifications/webhook.ts +++ b/packages/core/src/infra/notifications/webhook.ts @@ -8,6 +8,7 @@ import { type ChannelRenderer, ChannelSendError, type ChannelSendResult, + type ChannelSetup, type NotificationChannel, type PlatformMessageRef, type RenderContext, @@ -37,6 +38,18 @@ export const WEBHOOK_CAPABILITIES: ChannelCapabilities = { maxButtons: 5, }; +/** + * The capabilities of a webhook channel: with act buttons on, the `act` actions of the contract + * are carried as they are (no tokens); the receiver answers through the REST API (D-41). + * + * @returns The capabilities. + */ +export function webhookCapabilities(setup: Pick): ChannelCapabilities { + return setup.rules.act_buttons === true + ? { ...WEBHOOK_CAPABILITIES, actButtons: true } + : WEBHOOK_CAPABILITIES; +} + /** The URL of the channel as rendered: the literal URL, or `{secret:url}` from a variable. */ function urlOf(target: Readonly>): string { const literal = target['url']; @@ -50,7 +63,7 @@ function urlOf(target: Readonly>): string { */ export const webhookRenderer: ChannelRenderer = { kind: 'webhook', - capabilities: () => WEBHOOK_CAPABILITIES, + capabilities: webhookCapabilities, render(delivery: ChannelDelivery, context: RenderContext): readonly RenderedRequest[] { const { message, links } = delivery; const linkMap: Record = {}; @@ -166,7 +179,7 @@ export function createWebhookChannel( id: record.channelId, name: record.name, kind: 'webhook', - capabilities: WEBHOOK_CAPABILITIES, + capabilities: webhookCapabilities(record), async send(delivery: ChannelDelivery): Promise { await post(delivery, 'send', null); return { ref: { notification_id: delivery.message.id, revision: delivery.message.revision } }; diff --git a/packages/core/src/infra/persistence/generated/db.d.ts b/packages/core/src/infra/persistence/generated/db.d.ts index d897639..1a701ef 100644 --- a/packages/core/src/infra/persistence/generated/db.d.ts +++ b/packages/core/src/infra/persistence/generated/db.d.ts @@ -144,6 +144,35 @@ export interface Meta { value: string; } +export interface NotificationActions { + action_id: string; + action_label: string | null; + actor: string; + actor_name: string | null; + args_json: Generated; + at: number; + channel_id: string; + channel_kind: string; + channel_name: string; + detail: string | null; + notification_id: string | null; + op: string; + outcome: string; + seq: Generated; +} + +export interface NotificationActionTokens { + action_id: string; + args_json: Generated; + channel_id: string; + created_at: number; + expires_at: number; + notification_id: string; + op: string; + token_hash: string; + used_at: number | null; +} + export interface NotificationChannelMessages { channel_id: string; deleted_at: number | null; @@ -174,6 +203,12 @@ export interface NotificationChannels { updated_at: number; } +export interface NotificationCursors { + cursor_key: string; + updated_at: number; + value: string; +} + export interface NotificationDeliveries { attempts: Generated; channel_id: string; @@ -435,8 +470,11 @@ export interface DB { logs: Logs; mcp_connections: McpConnections; meta: Meta; + notification_action_tokens: NotificationActionTokens; + notification_actions: NotificationActions; notification_channel_messages: NotificationChannelMessages; notification_channels: NotificationChannels; + notification_cursors: NotificationCursors; notification_deliveries: NotificationDeliveries; notifications: Notifications; operator_actions: OperatorActions; diff --git a/packages/core/src/infra/persistence/mappers/notification-actions.ts b/packages/core/src/infra/persistence/mappers/notification-actions.ts new file mode 100644 index 0000000..3ddfb09 --- /dev/null +++ b/packages/core/src/infra/persistence/mappers/notification-actions.ts @@ -0,0 +1,94 @@ +/** @module infra/persistence/mappers/notification-actions — `notification_action_tokens` and `notification_actions` rows ↔ records (spec 03 §7, §9.6). */ + +import type { Insertable, Selectable } from 'kysely'; +import { NOTIFICATION_ACTION_OUTCOMES } from '../../../ports/persistence/enums.ts'; +import type { + NewNotificationAction, + NotificationActionRecord, + NotificationActionTokenRecord, +} from '../../../ports/persistence/records.ts'; +import type { NotificationActions, NotificationActionTokens } from '../generated/db.d.ts'; +import { parseEnum, parseJsonObject } from './codec.ts'; + +/** A command's args: string, number and boolean leaves only. */ +function argsOf(text: string, where: string): Readonly> { + const out: Record = {}; + for (const [k, v] of Object.entries(parseJsonObject(text, where))) { + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out[k] = v; + } + return out; +} + +/** `notification_action_tokens` row → record. */ +export function actionTokenFromRow( + row: Selectable, +): NotificationActionTokenRecord { + return { + tokenHash: row.token_hash, + channelId: row.channel_id, + notificationId: row.notification_id, + actionId: row.action_id, + op: row.op, + args: argsOf(row.args_json, `notification_action_tokens.args`), + createdAt: row.created_at, + expiresAt: row.expires_at, + usedAt: row.used_at, + }; +} + +/** Record → `notification_action_tokens` row. */ +export function actionTokenToRow( + record: NotificationActionTokenRecord, +): Insertable { + return { + token_hash: record.tokenHash, + channel_id: record.channelId, + notification_id: record.notificationId, + action_id: record.actionId, + op: record.op, + args_json: JSON.stringify(record.args), + created_at: record.createdAt, + expires_at: record.expiresAt, + used_at: record.usedAt, + }; +} + +/** `notification_actions` row → record. */ +export function actionFromRow(row: Selectable): NotificationActionRecord { + const where = `notification_actions.${row.seq}`; + return { + seq: Number(row.seq), + at: row.at, + channelId: row.channel_id, + channelName: row.channel_name, + channelKind: row.channel_kind, + notificationId: row.notification_id, + actionId: row.action_id, + actionLabel: row.action_label, + op: row.op, + args: argsOf(row.args_json, `${where}.args`), + actor: row.actor, + actorName: row.actor_name, + outcome: parseEnum(NOTIFICATION_ACTION_OUTCOMES, row.outcome, `${where}.outcome`), + detail: row.detail, + }; +} + +/** New press → `notification_actions` row. */ +export function actionToRow(row: NewNotificationAction): Insertable { + return { + at: row.at, + channel_id: row.channelId, + channel_name: row.channelName, + channel_kind: row.channelKind, + notification_id: row.notificationId, + action_id: row.actionId, + action_label: row.actionLabel, + op: row.op, + args_json: JSON.stringify(row.args), + actor: row.actor, + actor_name: row.actorName, + outcome: row.outcome, + detail: row.detail, + }; +} diff --git a/packages/core/src/infra/persistence/migrations/0006-notification-actions.ts b/packages/core/src/infra/persistence/migrations/0006-notification-actions.ts new file mode 100644 index 0000000..138fb93 --- /dev/null +++ b/packages/core/src/infra/persistence/migrations/0006-notification-actions.ts @@ -0,0 +1,64 @@ +/** @module infra/persistence/migrations/0006-notification-actions — schema v6: act-button command tokens, the press audit and the press listeners' resume cursors (spec 03 §7, §9.6; D-41, D-42). */ + +import { NOTIFICATION_ACTION_OUTCOMES } from '../../../ports/persistence/enums.ts'; +import type { Migration } from './migration.ts'; +import { sqlIn } from './sql-enums.ts'; + +/* + * Tokens are stored only as SHA-256 hashes and die with their channel or notification. The audit + * keeps the channel's name and kind and has no foreign keys, so it outlives both (audit class). + * `op` has no CHECK: `NotificationCommandOp` is an open set, like the notification kinds. + */ +const sql = ` +CREATE TABLE notification_action_tokens ( + token_hash TEXT PRIMARY KEY, + channel_id TEXT NOT NULL REFERENCES notification_channels(channel_id) ON DELETE CASCADE, + notification_id TEXT NOT NULL REFERENCES notifications(notification_id) ON DELETE CASCADE, + action_id TEXT NOT NULL, + op TEXT NOT NULL, + args_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + used_at INTEGER +) WITHOUT ROWID; +CREATE INDEX idx_notification_action_tokens_channel ON notification_action_tokens(channel_id); +CREATE INDEX idx_notification_action_tokens_notification ON notification_action_tokens(notification_id); +CREATE INDEX idx_notification_action_tokens_expiry ON notification_action_tokens(expires_at); + +CREATE TABLE notification_actions ( + seq INTEGER PRIMARY KEY, + at INTEGER NOT NULL, + channel_id TEXT NOT NULL, + channel_name TEXT NOT NULL, + channel_kind TEXT NOT NULL, + notification_id TEXT, + action_id TEXT NOT NULL, + action_label TEXT, + op TEXT NOT NULL, + args_json TEXT NOT NULL DEFAULT '{}', + actor TEXT NOT NULL, + actor_name TEXT, + outcome TEXT NOT NULL CHECK (outcome IN (${sqlIn(NOTIFICATION_ACTION_OUTCOMES)})), + detail TEXT +); +CREATE INDEX idx_notification_actions_at ON notification_actions(at); +CREATE INDEX idx_notification_actions_channel ON notification_actions(channel_id, seq); +CREATE INDEX idx_notification_actions_notification ON notification_actions(notification_id, seq) WHERE notification_id IS NOT NULL; + +CREATE TABLE notification_cursors ( + cursor_key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +) WITHOUT ROWID; +`; + +/** + * Schema v6. `compatible`: three new tables and nothing else, so a v5 reader still understands every + * table it knows. Nothing is backfilled. + */ +export const notificationActions: Migration = { + version: 6, + name: 'notification-actions', + compatible: true, + sql, +}; diff --git a/packages/core/src/infra/persistence/migrations/index.ts b/packages/core/src/infra/persistence/migrations/index.ts index aabbedb..fe9965c 100644 --- a/packages/core/src/infra/persistence/migrations/index.ts +++ b/packages/core/src/infra/persistence/migrations/index.ts @@ -5,6 +5,7 @@ import { notificationGroups } from './0002-notification-groups.ts'; import { harnessIdentity } from './0003-harness-identity.ts'; import { sessionBrowser } from './0004-session-browser.ts'; import { notificationOutbox } from './0005-notification-outbox.ts'; +import { notificationActions } from './0006-notification-actions.ts'; import type { Migration } from './migration.ts'; export type { Migration } from './migration.ts'; @@ -16,6 +17,7 @@ export const MIGRATIONS: readonly Migration[] = [ harnessIdentity, sessionBrowser, notificationOutbox, + notificationActions, ]; /** The schema version this binary writes. */ @@ -34,10 +36,13 @@ export const TABLES: readonly string[] = [ 'operator_requests', 'resource_samples', 'events', + 'notification_action_tokens', + 'notification_actions', 'notification_deliveries', 'notification_channel_messages', 'notifications', 'notification_channels', + 'notification_cursors', 'sessions', 'grants', 'auth_sessions', diff --git a/packages/core/src/infra/persistence/repositories/index.ts b/packages/core/src/infra/persistence/repositories/index.ts index 3eba008..bd544a1 100644 --- a/packages/core/src/infra/persistence/repositories/index.ts +++ b/packages/core/src/infra/persistence/repositories/index.ts @@ -12,6 +12,11 @@ import { import { SqliteBlocklistAuditRepository } from './blocklist-audit.ts'; import { SqliteEventLogRepository } from './event-log.ts'; import { SqliteCredentialRepository, SqlitePrincipalRepository } from './identity.ts'; +import { + SqliteNotificationActionRepository, + SqliteNotificationActionTokenRepository, + SqliteNotificationCursorRepository, +} from './notification-actions.ts'; import { SqliteNotificationChannelMessageRepository, SqliteNotificationChannelRepository, @@ -60,6 +65,9 @@ export function createRepositories(db: Kysely): Repositories { notificationChannels: new SqliteNotificationChannelRepository(db), notificationDeliveries: new SqliteNotificationDeliveryRepository(db), notificationChannelMessages: new SqliteNotificationChannelMessageRepository(db), + notificationActionTokens: new SqliteNotificationActionTokenRepository(db), + notificationActions: new SqliteNotificationActionRepository(db), + notificationCursors: new SqliteNotificationCursorRepository(db), preferences: new SqlitePreferenceRepository(db), systemEvents: new SqliteSystemEventRepository(db), idempotency: new SqliteIdempotencyRepository(db), diff --git a/packages/core/src/infra/persistence/repositories/notification-actions.ts b/packages/core/src/infra/persistence/repositories/notification-actions.ts new file mode 100644 index 0000000..8a9e8f0 --- /dev/null +++ b/packages/core/src/infra/persistence/repositories/notification-actions.ts @@ -0,0 +1,144 @@ +/** @module infra/persistence/repositories/notification-actions — SQLite `NotificationActionTokenRepository`, `NotificationActionRepository` and `NotificationCursorRepository` (spec 03 §7, §9.6). */ + +import type { Kysely } from 'kysely'; +import type { + NewNotificationAction, + NotificationActionListQuery, + NotificationActionRecord, + NotificationActionRepository, + NotificationActionTokenRecord, + NotificationActionTokenRepository, + NotificationCursorRepository, +} from '../../../ports/persistence/notification-actions.ts'; +import type { DB } from '../generated/db.d.ts'; +import { + actionFromRow, + actionTokenFromRow, + actionTokenToRow, + actionToRow, +} from '../mappers/notification-actions.ts'; + +/** SQLite implementation of {@link NotificationActionTokenRepository}. */ +export class SqliteNotificationActionTokenRepository implements NotificationActionTokenRepository { + readonly #db: Kysely; + + constructor(db: Kysely) { + this.#db = db; + } + + async insert(rows: readonly NotificationActionTokenRecord[]): Promise { + if (rows.length === 0) return; + await this.#db + .insertInto('notification_action_tokens') + .values(rows.map(actionTokenToRow)) + .execute(); + } + + async get(tokenHash: string): Promise { + const row = await this.#db + .selectFrom('notification_action_tokens') + .selectAll() + .where('token_hash', '=', tokenHash) + .executeTakeFirst(); + return row === undefined ? null : actionTokenFromRow(row); + } + + async claim(tokenHash: string, at: number): Promise { + const result = await this.#db + .updateTable('notification_action_tokens') + .set({ used_at: at }) + .where('token_hash', '=', tokenHash) + .where('used_at', 'is', null) + .executeTakeFirst(); + return result.numUpdatedRows > 0n; + } + + async prune(before: number): Promise { + const result = await this.#db + .deleteFrom('notification_action_tokens') + .where('expires_at', '<', before) + .executeTakeFirst(); + return Number(result.numDeletedRows); + } +} + +/** SQLite implementation of {@link NotificationActionRepository}. */ +export class SqliteNotificationActionRepository implements NotificationActionRepository { + readonly #db: Kysely; + + constructor(db: Kysely) { + this.#db = db; + } + + async insert(row: NewNotificationAction): Promise { + const result = await this.#db + .insertInto('notification_actions') + .values(actionToRow(row)) + .executeTakeFirst(); + const seq = Number(result.insertId ?? 0n); + if (seq <= 0) throw new Error('notification_actions insert returned no seq'); + return { ...row, seq }; + } + + async get(seq: number): Promise { + const row = await this.#db + .selectFrom('notification_actions') + .selectAll() + .where('seq', '=', seq) + .executeTakeFirst(); + return row === undefined ? null : actionFromRow(row); + } + + async list(query: NotificationActionListQuery): Promise { + let q = this.#db.selectFrom('notification_actions').selectAll(); + if (query.channelId !== undefined) q = q.where('channel_id', '=', query.channelId); + if (query.notificationId !== undefined) + q = q.where('notification_id', '=', query.notificationId); + if (query.outcomes !== undefined && query.outcomes.length > 0) + q = q.where('outcome', 'in', [...query.outcomes]); + if (query.beforeSeq !== undefined) q = q.where('seq', '<', query.beforeSeq); + const rows = await q + .orderBy('seq', 'desc') + .limit(query.limit ?? 50) + .execute(); + return rows.map(actionFromRow); + } + + async prune(before: number): Promise { + const result = await this.#db + .deleteFrom('notification_actions') + .where('at', '<', before) + .executeTakeFirst(); + return Number(result.numDeletedRows); + } +} + +/** SQLite implementation of {@link NotificationCursorRepository}. */ +export class SqliteNotificationCursorRepository implements NotificationCursorRepository { + readonly #db: Kysely; + + constructor(db: Kysely) { + this.#db = db; + } + + async get(key: string): Promise { + const row = await this.#db + .selectFrom('notification_cursors') + .select('value') + .where('cursor_key', '=', key) + .executeTakeFirst(); + return row?.value ?? null; + } + + async set(key: string, value: string, at: number): Promise { + await this.#db + .insertInto('notification_cursors') + .values({ cursor_key: key, value, updated_at: at }) + .onConflict((oc) => oc.column('cursor_key').doUpdateSet({ value, updated_at: at })) + .execute(); + } + + async remove(key: string): Promise { + await this.#db.deleteFrom('notification_cursors').where('cursor_key', '=', key).execute(); + } +} diff --git a/packages/core/src/infra/persistence/retention.ts b/packages/core/src/infra/persistence/retention.ts index 73e7373..45bba69 100644 --- a/packages/core/src/infra/persistence/retention.ts +++ b/packages/core/src/infra/persistence/retention.ts @@ -58,7 +58,8 @@ class Sweep { | 'vault_access' | 'blocked_requests' | 'auth_events' - | 'operator_actions', + | 'operator_actions' + | 'notification_actions', col: string, cutoff: number, ): Promise { @@ -117,6 +118,9 @@ class Sweep { await this.step('operator_actions', () => this.deleteWhere('operator_actions', 'occurred_at', cutoff), ); + await this.step('notification_actions', () => + this.deleteWhere('notification_actions', 'at', cutoff), + ); await this.step('operator_requests', async () => { const result = await this.ctx.db .deleteFrom('operator_requests') @@ -233,6 +237,17 @@ class Sweep { }); } + /** Act-button tokens (D-41) that expired before `cutoff`, used or not. */ + async pruneActionTokens(cutoff: number): Promise { + await this.step('notification_action_tokens', async () => { + const result = await this.ctx.db + .deleteFrom('notification_action_tokens') + .where('expires_at', '<', cutoff) + .executeTakeFirst(); + return Number(result.numDeletedRows); + }); + } + async oldestTelemetryTs(): Promise { const result = await sql<{ m: number | null }>` SELECT MIN(ts) AS m FROM ( @@ -270,6 +285,7 @@ export async function sweepRetention( now - (policy.notificationDays ?? 90) * DAY_MS, ); await sweep.pruneNotificationDeliveries(now - (policy.notificationDeliveryDays ?? 30) * DAY_MS); + await sweep.pruneActionTokens(now - DAY_MS); await sweep.step('idempotency_keys', async () => { const result = await ctx.db .deleteFrom('idempotency_keys') diff --git a/packages/core/src/infra/telemetry/metrics.ts b/packages/core/src/infra/telemetry/metrics.ts index 1409b35..86b794b 100644 --- a/packages/core/src/infra/telemetry/metrics.ts +++ b/packages/core/src/infra/telemetry/metrics.ts @@ -22,6 +22,7 @@ export const METRIC = { BLOCKLIST_HITS: 'browserhive.blocklist.hits', RETENTION_PRUNED_ROWS: 'browserhive.retention.pruned_rows', NOTIFICATION_DELIVERIES: 'browserhive.notifications.deliveries', + NOTIFICATION_ACTIONS: 'browserhive.notifications.actions', PROCESS_RSS_BYTES: 'browserhive.process.rss_bytes', PROCESS_HEAP_BYTES: 'browserhive.process.heap_bytes', PROCESS_EVENT_LOOP_LAG: 'browserhive.process.event_loop_lag', @@ -51,6 +52,8 @@ export interface Instruments { readonly retentionPrunedRows: Counter; /** Outbox jobs by `channel_kind` and `status` (D-34). */ readonly notificationDeliveries: Counter; + /** Act-button presses by `channel_kind` and `outcome` (D-41). */ + readonly notificationActions: Counter; readonly processRssBytes: ObservableGauge; readonly processHeapBytes: ObservableGauge; readonly processEventLoopLag: ObservableGauge; @@ -191,6 +194,13 @@ export function createInstruments(meter: Meter): Instruments { }), ); }, + get notificationActions() { + return lazy(METRIC.NOTIFICATION_ACTIONS, () => + meter.createCounter(METRIC.NOTIFICATION_ACTIONS, { + description: 'Act-button presses by channel_kind and outcome', + }), + ); + }, get processRssBytes() { return lazy(METRIC.PROCESS_RSS_BYTES, () => meter.createObservableGauge(METRIC.PROCESS_RSS_BYTES, { diff --git a/packages/core/src/interface/http/routes/channels.ts b/packages/core/src/interface/http/routes/channels.ts index 2eaaf77..fc7e964 100644 --- a/packages/core/src/interface/http/routes/channels.ts +++ b/packages/core/src/interface/http/routes/channels.ts @@ -1,6 +1,8 @@ -/** @module interface/http/routes/channels — notification channels: CRUD, pause/resume, test send, preview, the delivery log, the environment check and the Telegram connect flow (spec 03 §4.8.1). */ +/** @module interface/http/routes/channels — notification channels: CRUD, pause/resume, test send, preview, the delivery log, the environment check, the Telegram connect flow, the Discord bot setup and the act-button audit (spec 03 §4.8.1). */ import { + ActionsPage, + ActionsQuery, ChannelEnvQuery, ChannelEnvResponse, ChannelIdParams, @@ -15,6 +17,14 @@ import { DeliveriesQuery, DeliveryDetailResponse, DeliverySeqParams, + DiscordBotInfo, + DiscordBotRequest, + DiscordChannelsRequest, + DiscordChannelsResponse, + DiscordConnectParams, + DiscordConnectRequest, + DiscordConnectResponse, + DiscordConnectStatus, OkResponse, TelegramConnectParams, TelegramConnectRequest, @@ -130,6 +140,85 @@ export const CHANNEL_ROUTES = [ return reply(200, services.channels.telegramConnectStatus(input.params.connect_id)); }, }), + defineRoute({ + operationId: 'getDiscordBot', + tags, + summary: + 'Who the Discord bot is, its invite link (minimal permissions) and the servers it is in.', + request: { body: DiscordBotRequest }, + responses: { 200: DiscordBotInfo }, + errors: ['CHANNEL_NOT_READY', 'CHANNEL_PLATFORM_ERROR'], + rateLimit: { limit: 20, windowMs: 60_000, key: 'principal' }, + async handler({ input, services }) { + return reply(200, await services.channels.discordBot(input.body.token_env)); + }, + }), + defineRoute({ + operationId: 'listDiscordChannels', + tags, + summary: "The text channels of one of the Discord bot's servers (the channel picker).", + request: { body: DiscordChannelsRequest }, + responses: { 200: DiscordChannelsResponse }, + errors: ['CHANNEL_NOT_READY', 'CHANNEL_PLATFORM_ERROR'], + rateLimit: { limit: 20, windowMs: 60_000, key: 'principal' }, + async handler({ input, services }) { + return reply( + 200, + await services.channels.discordChannels(input.body.token_env, input.body.guild_id), + ); + }, + }), + defineRoute({ + operationId: 'startDiscordConnect', + tags, + summary: + 'Link your Discord account: the bot posts a "This is me" button and waits 2 minutes for it.', + request: { body: DiscordConnectRequest }, + responses: { 200: DiscordConnectResponse }, + errors: ['CHANNEL_NOT_READY', 'CHANNEL_PLATFORM_ERROR'], + rateLimit: { limit: 6, windowMs: 60_000, key: 'principal' }, + async handler({ input, services }) { + return reply( + 200, + await services.channels.discordConnect(input.body.token_env, input.body.channel_id), + ); + }, + }), + defineRoute({ + operationId: 'getDiscordConnect', + tags, + summary: + 'State of a Discord account link: waiting, connected (with the user), expired, failed.', + request: { params: DiscordConnectParams }, + responses: { 200: DiscordConnectStatus }, + async handler({ input, services }) { + return reply(200, services.channels.discordConnectStatus(input.params.connect_id)); + }, + }), + defineRoute({ + operationId: 'listChannelActions', + tags, + summary: + 'The act-button audit newest first: who pressed what, from which chat, and the outcome.', + request: { query: ActionsQuery }, + responses: { 200: ActionsPage }, + async handler({ input, services, ctx }) { + const q = input.query; + const page = await services.channels.actions({ + limit: q.limit, + ...(q.cursor !== undefined && { cursor: q.cursor }), + ...(q.channel_id !== undefined && { channelId: q.channel_id }), + ...(q.notification_id !== undefined && { notificationId: q.notification_id }), + ...(q.outcome !== undefined && { outcomes: q.outcome }), + }); + return reply(200, { + data: [...page.items], + page: { next_cursor: page.nextCursor, limit: q.limit }, + applied: { filters: appliedFilters(q), sort: { key: 'seq', dir: 'desc' } }, + meta: { now: ctx.now }, + }); + }, + }), defineRoute({ operationId: 'getChannel', tags, diff --git a/packages/core/src/interface/http/services.ts b/packages/core/src/interface/http/services.ts index 22626b0..fc6125f 100644 --- a/packages/core/src/interface/http/services.ts +++ b/packages/core/src/interface/http/services.ts @@ -304,6 +304,11 @@ export type ChannelsPort = Pick< | 'env' | 'telegramConnect' | 'telegramConnectStatus' + | 'discordBot' + | 'discordChannels' + | 'discordConnect' + | 'discordConnectStatus' + | 'actions' >; /** The `publicUrl` check (a structural slice of `PublicUrlChecker`). */ diff --git a/packages/core/src/ports/notification-channel.ts b/packages/core/src/ports/notification-channel.ts index ca53840..8aa4d1d 100644 --- a/packages/core/src/ports/notification-channel.ts +++ b/packages/core/src/ports/notification-channel.ts @@ -1,7 +1,11 @@ /** @module ports/notification-channel — the delivery seam for notifications (D-16, D-32, spec 03 §9.3): what a channel can render, and send / edit / delete of one platform message. The in-app inbox and every platform adapter implement it. */ +import type { NotificationListenerState } from '@browserhive/contracts/enums'; import type { Notification } from '@browserhive/contracts/http'; -import type { NotificationMessage } from '@browserhive/contracts/notifications'; +import type { + NotificationChannelRules, + NotificationMessage, +} from '@browserhive/contracts/notifications'; import type { PlatformMessageRef } from './persistence/records-notifications.ts'; export type { PlatformMessageRef } from './persistence/records-notifications.ts'; @@ -57,6 +61,12 @@ export interface ChannelDelivery { * adapters never receive it and must not depend on it. */ readonly inbox?: Notification; + /** + * The payload of each act button (`bh1:`, D-41), by action id. Set by the outbox only + * when the channel receives presses and the message carries act actions; the tokens were written + * before the delivery was handed over. + */ + readonly actTokens?: ReadonlyMap; } /** What a successful send or edit returns: the platform's coordinates of the message. */ @@ -118,6 +128,76 @@ export interface NotificationChannel { edit?(ref: PlatformMessageRef, delivery: ChannelDelivery): Promise; /** Deletes a sent message. Required when `capabilities.delete`. */ delete?(ref: PlatformMessageRef): Promise; + /** + * The channel's press listener (Telegram poller, Discord gateway, ntfy reply topic; D-41), when + * act buttons are on and the platform can receive presses. Its buttons need command tokens. + */ + readonly presses?: PressSource; +} + +/** Who pressed an act button. */ +export interface PressActor { + readonly platform: 'telegram' | 'discord' | 'ntfy'; + /** The platform user id (`null` on ntfy, which has no user identity). */ + readonly id: string | null; + /** The platform's display name, when it gives one. */ + readonly name: string | null; +} + +/** One act-button press as a listener received it. */ +export interface PressEvent { + /** The token (the part after `bh1:`). Never logged. */ + readonly token: string; + /** Where the press came from: the Telegram chat id, the Discord channel id; `null` on ntfy. */ + readonly origin: string | null; + readonly actor: PressActor; +} + +/** What the presser is told. */ +export interface PressAnswer { + /** The audit outcome, or `unknown` for a token BrowserHive never minted. */ + readonly outcome: string; + /** Short text for the chat (a Telegram toast, an ephemeral Discord reply). */ + readonly text: string; + /** Whether the answer is a refusal the presser should notice (a Telegram alert). */ + readonly refused: boolean; +} + +/** Handles one press: checks, runs the command, audits (D-41). Never throws. */ +export type PressHandler = (press: PressEvent) => Promise; + +/** A press listener's state (`ChannelView.connection`). */ +export interface ListenerStatus { + readonly state: NotificationListenerState; + readonly since: number; + /** Why it is offline or reconnecting; `null` when connected. */ + readonly detail: string | null; +} + +/** + * The inbound half of a channel that accepts act buttons. Listening is outbound only (D-33): a + * long poll, a gateway WebSocket, a streaming subscription. Several channels on one bot share one + * connection, which closes shortly after its last listener stops. + */ +export interface PressSource { + /** + * Starts delivering this channel's presses to `handler` and state changes to `onStatus`. + * + * @returns Stops listening. + */ + listen(handler: PressHandler, onStatus: (status: ListenerStatus) => void): () => void; + /** The current state. */ + status(): ListenerStatus; +} + +/** What a renderer needs to know about a channel's setup to declare its capabilities. */ +export interface ChannelSetup { + /** Discord `webhook`/`bot`; `null` elsewhere. */ + readonly mode: string | null; + readonly target: Readonly>; + /** Secret parameter → variable name (never a value). */ + readonly secretRefs: Readonly>; + readonly rules: NotificationChannelRules; } /** @@ -167,8 +247,8 @@ export interface RenderContext { */ export interface ChannelRenderer { readonly kind: string; - /** What this platform renders in `mode`. */ - capabilities(mode: string | null): ChannelCapabilities; + /** What this platform renders for a channel set up like this (mode, act buttons). */ + capabilities(setup: ChannelSetup): ChannelCapabilities; /** The request(s) for one send or edit, in order. Throws only on a programming error. */ render(delivery: ChannelDelivery, context: RenderContext): readonly RenderedRequest[]; } @@ -226,6 +306,43 @@ export interface TelegramSetup { ): Promise; } +/** Who the Discord bot is and where it is (the bot-mode setup, D-38). */ +export interface DiscordBotIdentity { + readonly applicationId: string; + readonly botId: string; + readonly username: string; + readonly guilds: readonly { readonly id: string; readonly name: string }[]; +} + +/** A text channel of a Discord server. */ +export interface DiscordChannelInfo { + readonly id: string; + readonly name: string; + readonly type: 'text' | 'announcement'; + readonly category: string | null; +} + +/** + * The Discord bot-mode setup calls (spec 03 §4.8.1): the bot's identity and servers, a server's + * text channels, and the one-time "This is me" claim that names the operator's account. + */ +export interface DiscordSetup { + /** Throws a `ChannelSendError` (`auth` for a refused token). */ + bot(token: string): Promise; + channels(token: string, guildId: string): Promise; + /** + * Posts a "This is me" button in `channelId` and waits (over the gateway) for its press until + * the signal aborts or `deadline` passes; the message is deleted afterwards. + * + * @returns Who pressed it, or `null` on timeout/abort. + */ + claim( + token: string, + channelId: string, + options: { readonly signal: AbortSignal; readonly deadline: number }, + ): Promise<{ readonly id: string; readonly name: string } | null>; +} + /** Outcome of one HTTP probe of `/health` (spec 08 §5.8). */ export type UrlProbeResult = | { diff --git a/packages/core/src/ports/persistence/enums.test.ts b/packages/core/src/ports/persistence/enums.test.ts index 20c3ad4..901f1b7 100644 --- a/packages/core/src/ports/persistence/enums.test.ts +++ b/packages/core/src/ports/persistence/enums.test.ts @@ -9,6 +9,7 @@ import { ClosedReason, CredentialKind, DegradationSeverity, + NotificationActionOutcome, NotificationChannelSource, NotificationChannelStatus, NotificationDeliveryOp, @@ -31,6 +32,7 @@ import { CLOSED_REASONS, CREDENTIAL_KINDS, MCP_TRANSPORTS, + NOTIFICATION_ACTION_OUTCOMES, NOTIFICATION_CHANNEL_SOURCES, NOTIFICATION_CHANNEL_STATUSES, NOTIFICATION_DELIVERY_OPS, @@ -65,6 +67,7 @@ describe('persistence enums', () => { [NOTIFICATION_CHANNEL_SOURCES, NotificationChannelSource.options], [NOTIFICATION_DELIVERY_OPS, NotificationDeliveryOp.options], [NOTIFICATION_DELIVERY_STATUSES, NotificationDeliveryStatus.options], + [NOTIFICATION_ACTION_OUTCOMES, NotificationActionOutcome.options], [OPERATOR_REQUEST_KINDS, OperatorRequestKind.options], [OPERATOR_REQUEST_STATUSES, OperatorRequestStatus.options], [PERSISTENCE_MODES, PersistenceMode.options], diff --git a/packages/core/src/ports/persistence/enums.ts b/packages/core/src/ports/persistence/enums.ts index 087d0c8..ab9aa2c 100644 --- a/packages/core/src/ports/persistence/enums.ts +++ b/packages/core/src/ports/persistence/enums.ts @@ -183,6 +183,20 @@ export const NOTIFICATION_DELIVERY_STATUSES = [ /** Element of {@link NOTIFICATION_DELIVERY_STATUSES}. */ export type NotificationDeliveryStatus = (typeof NOTIFICATION_DELIVERY_STATUSES)[number]; +/** How an act-button press ended (`notification_actions.outcome`, D-41). */ +export const NOTIFICATION_ACTION_OUTCOMES = [ + 'done', + 'failed', + 'not_allowed', + 'used', + 'expired', + 'stale', + 'wrong_channel', + 'disabled', +] as const; +/** Element of {@link NOTIFICATION_ACTION_OUTCOMES}. */ +export type NotificationActionOutcome = (typeof NOTIFICATION_ACTION_OUTCOMES)[number]; + /** Severity of a `system_events` row. */ export const SYSTEM_EVENT_SEVERITIES = ['info', 'warn', 'error'] as const; /** Element of {@link SYSTEM_EVENT_SEVERITIES}. */ diff --git a/packages/core/src/ports/persistence/index.ts b/packages/core/src/ports/persistence/index.ts index 31276bd..b0d3132 100644 --- a/packages/core/src/ports/persistence/index.ts +++ b/packages/core/src/ports/persistence/index.ts @@ -22,6 +22,7 @@ export type { ClosedReason, CredentialKind, McpTransport, + NotificationActionOutcome, NotificationChannelSource, NotificationChannelStatus, NotificationDeliveryOp, @@ -52,6 +53,7 @@ export { CLOSED_REASONS, CREDENTIAL_KINDS, MCP_TRANSPORTS, + NOTIFICATION_ACTION_OUTCOMES, NOTIFICATION_CHANNEL_SOURCES, NOTIFICATION_CHANNEL_STATUSES, NOTIFICATION_DELIVERY_OPS, @@ -91,6 +93,15 @@ export type { RetentionPolicy, RetentionResult, } from './maintenance.ts'; +export type { + NewNotificationAction, + NotificationActionListQuery, + NotificationActionRecord, + NotificationActionRepository, + NotificationActionTokenRecord, + NotificationActionTokenRepository, + NotificationCursorRepository, +} from './notification-actions.ts'; export type { NotificationChannelMessageRepository, NotificationChannelRepository, diff --git a/packages/core/src/ports/persistence/notification-actions.ts b/packages/core/src/ports/persistence/notification-actions.ts new file mode 100644 index 0000000..5761de6 --- /dev/null +++ b/packages/core/src/ports/persistence/notification-actions.ts @@ -0,0 +1,91 @@ +/** @module ports/persistence/notification-actions — act-button command tokens, the press audit and the press listeners' resume cursors (spec 03 §7.2, §9.6; D-41, D-42). */ + +import type { NotificationActionOutcome } from './enums.ts'; + +/** One command token (`notification_action_tokens`). The token itself is never stored. */ +export interface NotificationActionTokenRecord { + /** SHA-256 hex of the token (the part after `bh1:`). */ + readonly tokenHash: string; + readonly channelId: string; + readonly notificationId: string; + /** The contract action's id (`resolve`, `reject`, `approve`, `deny`). */ + readonly actionId: string; + /** `NotificationCommandOp`. */ + readonly op: string; + readonly args: Readonly>; + readonly createdAt: number; + readonly expiresAt: number; + readonly usedAt: number | null; +} + +/** One audited press (`notification_actions`). */ +export interface NotificationActionRecord { + readonly seq: number; + readonly at: number; + readonly channelId: string; + readonly channelName: string; + readonly channelKind: string; + readonly notificationId: string | null; + readonly actionId: string; + readonly actionLabel: string | null; + readonly op: string; + readonly args: Readonly>; + /** `telegram:`, `discord:`, `ntfy:topic-b`. */ + readonly actor: string; + readonly actorName: string | null; + readonly outcome: NotificationActionOutcome; + readonly detail: string | null; +} + +/** A press to audit (`seq` is assigned). */ +export type NewNotificationAction = Omit; + +/** Filters of the press audit (newest first, keyset on `seq`). */ +export interface NotificationActionListQuery { + readonly channelId?: string; + readonly notificationId?: string; + readonly outcomes?: readonly NotificationActionOutcome[]; + readonly beforeSeq?: number; + readonly limit?: number; +} + +/** Repository over `notification_action_tokens`. */ +export interface NotificationActionTokenRepository { + /** Inserts tokens (a duplicate hash is a programming error and throws). */ + insert(rows: readonly NotificationActionTokenRecord[]): Promise; + get(tokenHash: string): Promise; + /** + * Claims a token: `usedAt` goes from `null` to `at`, once. + * + * @returns False when it was already used (another press won) or is unknown. + */ + claim(tokenHash: string, at: number): Promise; + /** + * Deletes tokens that expired before `before` (used or not). + * + * @returns The number of rows deleted. + */ + prune(before: number): Promise; +} + +/** Repository over `notification_actions` (audit class). */ +export interface NotificationActionRepository { + /** Appends one press; returns the stored row. */ + insert(row: NewNotificationAction): Promise; + get(seq: number): Promise; + /** The audit, newest first. */ + list(query: NotificationActionListQuery): Promise; + /** + * Deletes rows older than `before`. + * + * @returns The number of rows deleted. + */ + prune(before: number): Promise; +} + +/** Repository over `notification_cursors`: where each press listener resumes. */ +export interface NotificationCursorRepository { + get(key: string): Promise; + set(key: string, value: string, at: number): Promise; + remove(key: string): Promise; +} diff --git a/packages/core/src/ports/persistence/records.ts b/packages/core/src/ports/persistence/records.ts index a0db50c..686d18b 100644 --- a/packages/core/src/ports/persistence/records.ts +++ b/packages/core/src/ports/persistence/records.ts @@ -17,6 +17,12 @@ import type { VaultAccessResult, } from './enums.ts'; +export type { + NewNotificationAction, + NotificationActionListQuery, + NotificationActionRecord, + NotificationActionTokenRecord, +} from './notification-actions.ts'; export type { AuthEventRecord, AuthSessionRecord, diff --git a/packages/core/src/ports/persistence/unit-of-work.ts b/packages/core/src/ports/persistence/unit-of-work.ts index fac285f..f7391f5 100644 --- a/packages/core/src/ports/persistence/unit-of-work.ts +++ b/packages/core/src/ports/persistence/unit-of-work.ts @@ -9,6 +9,11 @@ import type { GrantRepository, PrincipalRepository, } from './identity.ts'; +import type { + NotificationActionRepository, + NotificationActionTokenRepository, + NotificationCursorRepository, +} from './notification-actions.ts'; import type { NotificationChannelMessageRepository, NotificationChannelRepository, @@ -54,6 +59,9 @@ export interface Repositories { readonly notificationChannels: NotificationChannelRepository; readonly notificationDeliveries: NotificationDeliveryRepository; readonly notificationChannelMessages: NotificationChannelMessageRepository; + readonly notificationActionTokens: NotificationActionTokenRepository; + readonly notificationActions: NotificationActionRepository; + readonly notificationCursors: NotificationCursorRepository; readonly preferences: PreferenceRepository; readonly systemEvents: SystemEventRepository; readonly idempotency: IdempotencyRepository; diff --git a/packages/core/src/public/server.ts b/packages/core/src/public/server.ts index 67fae95..6589d44 100644 --- a/packages/core/src/public/server.ts +++ b/packages/core/src/public/server.ts @@ -6,6 +6,8 @@ export { type BlocklistFileWatcher, BlocklistService } from '../app/blocklist/bl export { configView } from '../app/config/provenance-view.ts'; export { InProcessEventBus } from '../app/events/bus.ts'; export { + type ActionCounter, + type ActionExecutor, type ChannelAdapterFactory, ChannelRegistry, ChannelService, @@ -13,6 +15,8 @@ export { type DeliveryCounter, imageVariants, linkBuilderFor, + NotificationActionListeners, + NotificationActionService, NotificationOutbox, NotificationService, PublicUrlChecker, diff --git a/packages/core/test/goldens/notifications/discord/attention-act.json b/packages/core/test/goldens/notifications/discord/attention-act.json new file mode 100644 index 0000000..20afa95 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/attention-act.json @@ -0,0 +1,78 @@ +{ + "kind": "discord", + "variant": "attention-act", + "mode": "webhook", + "requests": [ + { + "method": "POST", + "path": "{secret:webhook}?wait=true&with_components=true", + "encoding": "json", + "body": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1" + }, + { + "type": 2, + "style": 5, + "label": "Open in BrowserHive", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1" + } + ] + } + ], + "embeds": [ + { + "title": "⚠️ Attention requested", + "description": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "color": 16096779, + "fields": [ + { + "name": "Mode", + "value": "takeover", + "inline": true + }, + { + "name": "Session", + "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)", + "inline": true + }, + { + "name": "Page", + "value": "`https://shop.example.com/checkout/payment`", + "inline": true + }, + { + "name": "Tool", + "value": "`click`", + "inline": true + }, + { + "name": "Waiting since", + "value": "", + "inline": true + } + ], + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:13:20.000Z" + } + ] + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/discord/attention-bot-no-act.json b/packages/core/test/goldens/notifications/discord/attention-bot-no-act.json new file mode 100644 index 0000000..af80da1 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/attention-bot-no-act.json @@ -0,0 +1,78 @@ +{ + "kind": "discord", + "variant": "attention-bot-no-act", + "mode": "bot", + "requests": [ + { + "method": "POST", + "path": "/channels/{channel_id}/messages", + "encoding": "json", + "body": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1" + }, + { + "type": 2, + "style": 5, + "label": "Open in BrowserHive", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1" + } + ] + } + ], + "embeds": [ + { + "title": "⚠️ Attention requested", + "description": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "color": 16096779, + "fields": [ + { + "name": "Mode", + "value": "takeover", + "inline": true + }, + { + "name": "Session", + "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)", + "inline": true + }, + { + "name": "Page", + "value": "`https://shop.example.com/checkout/payment`", + "inline": true + }, + { + "name": "Tool", + "value": "`click`", + "inline": true + }, + { + "name": "Waiting since", + "value": "", + "inline": true + } + ], + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:13:20.000Z" + } + ] + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/discord/attention-bot.json b/packages/core/test/goldens/notifications/discord/attention-bot.json index 38fdf97..afd9e98 100644 --- a/packages/core/test/goldens/notifications/discord/attention-bot.json +++ b/packages/core/test/goldens/notifications/discord/attention-bot.json @@ -5,7 +5,7 @@ "requests": [ { "method": "POST", - "path": "{secret:webhook}?wait=true&with_components=true", + "path": "/channels/112233445566778899/messages", "encoding": "json", "body": { "content": null, @@ -26,13 +26,13 @@ "type": 2, "style": 2, "label": "Mark resolved", - "custom_id": "bh1:preview" + "custom_id": "bh1:preview-resolve" }, { "type": 2, "style": 4, "label": "Reject", - "custom_id": "bh1:preview" + "custom_id": "bh1:preview-reject" } ] } diff --git a/packages/core/test/goldens/notifications/discord/attention-image-act.json b/packages/core/test/goldens/notifications/discord/attention-image-act.json new file mode 100644 index 0000000..1cfdd63 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/attention-image-act.json @@ -0,0 +1,93 @@ +{ + "kind": "discord", + "variant": "attention-image-act", + "mode": "webhook", + "requests": [ + { + "method": "POST", + "path": "{secret:webhook}?wait=true&with_components=true", + "encoding": "multipart", + "body": { + "payload_json": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1" + }, + { + "type": 2, + "style": 5, + "label": "Open in BrowserHive", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1" + } + ] + } + ], + "embeds": [ + { + "title": "⚠️ Attention requested", + "description": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "color": 16096779, + "fields": [ + { + "name": "Mode", + "value": "takeover", + "inline": true + }, + { + "name": "Session", + "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)", + "inline": true + }, + { + "name": "Page", + "value": "`https://shop.example.com/checkout/payment`", + "inline": true + }, + { + "name": "Tool", + "value": "`click`", + "inline": true + }, + { + "name": "Waiting since", + "value": "", + "inline": true + } + ], + "image": { + "url": "attachment://screenshot.jpg" + }, + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:13:20.000Z" + } + ], + "attachments": [ + { + "id": 0, + "filename": "screenshot.jpg" + } + ] + } + }, + "headers": {}, + "file": { + "ref": "nimg-sample", + "name": "screenshot.jpg", + "content_type": "image/jpeg" + } + } + ] +} diff --git a/packages/core/test/goldens/notifications/discord/attention-resolved-bot-edit.json b/packages/core/test/goldens/notifications/discord/attention-resolved-bot-edit.json new file mode 100644 index 0000000..cf166b8 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/attention-resolved-bot-edit.json @@ -0,0 +1,70 @@ +{ + "kind": "discord", + "variant": "attention-resolved-bot-edit", + "mode": "bot", + "requests": [ + { + "method": "PATCH", + "path": "/channels/112233445566778899/messages/1101", + "encoding": "json", + "body": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [], + "embeds": [ + { + "title": "✅ Attention requested", + "description": "Resolved by admin after 2m 10s\n\n> CAPTCHA on the checkout page: please solve it, then resume", + "color": 2278750, + "fields": [ + { + "name": "Mode", + "value": "takeover", + "inline": true + }, + { + "name": "Session", + "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)", + "inline": true + }, + { + "name": "Page", + "value": "`https://shop.example.com/checkout/payment`", + "inline": true + }, + { + "name": "Tool", + "value": "`click`", + "inline": true + }, + { + "name": "Waiting since", + "value": "", + "inline": true + }, + { + "name": "Outcome", + "value": "resolved", + "inline": true + }, + { + "name": "Settled", + "value": "", + "inline": true + } + ], + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:15:30.000Z" + } + ], + "attachments": [] + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/discord/vault-confirm-act.json b/packages/core/test/goldens/notifications/discord/vault-confirm-act.json new file mode 100644 index 0000000..6092c34 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/vault-confirm-act.json @@ -0,0 +1,72 @@ +{ + "kind": "discord", + "variant": "vault-confirm-act", + "mode": "webhook", + "requests": [ + { + "method": "POST", + "path": "{secret:webhook}?wait=true&with_components=true", + "encoding": "json", + "body": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Review in BrowserHive", + "url": "https://bh.example.net/vault?tab=confirm" + } + ] + } + ], + "embeds": [ + { + "title": "⚠️ Vault fill awaiting confirm", + "description": "entry github — approve or deny the release", + "url": "https://bh.example.net/vault?tab=confirm", + "color": 16096779, + "fields": [ + { + "name": "Entry", + "value": "`github`", + "inline": true + }, + { + "name": "Session", + "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)", + "inline": true + }, + { + "name": "Page", + "value": "`https://github.com/login`", + "inline": true + }, + { + "name": "Tool", + "value": "`vault_fill`", + "inline": true + }, + { + "name": "Waiting since", + "value": "", + "inline": true + } + ], + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:13:20.000Z" + } + ] + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/ntfy/attention-act.json b/packages/core/test/goldens/notifications/ntfy/attention-act.json new file mode 100644 index 0000000..e635671 --- /dev/null +++ b/packages/core/test/goldens/notifications/ntfy/attention-act.json @@ -0,0 +1,50 @@ +{ + "kind": "ntfy", + "variant": "attention-act", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "/", + "encoding": "json", + "body": { + "topic": "bh-alerts", + "title": "Attention requested", + "message": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC", + "priority": 4, + "tags": [ + "warning" + ], + "click": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "actions": [ + { + "action": "http", + "label": "Mark resolved", + "url": "https://ntfy.example.net/bh-replies", + "method": "POST", + "body": "bh1:preview-resolve", + "clear": true + }, + { + "action": "http", + "label": "Reject", + "url": "https://ntfy.example.net/bh-replies", + "method": "POST", + "body": "bh1:preview-reject", + "clear": true + }, + { + "action": "view", + "label": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "clear": false + } + ], + "markdown": false, + "sequence_id": "n-sample000001" + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/ntfy/attention-image-act.json b/packages/core/test/goldens/notifications/ntfy/attention-image-act.json new file mode 100644 index 0000000..5ea3a5b --- /dev/null +++ b/packages/core/test/goldens/notifications/ntfy/attention-image-act.json @@ -0,0 +1,52 @@ +{ + "kind": "ntfy", + "variant": "attention-image-act", + "mode": null, + "requests": [ + { + "method": "PUT", + "path": "/bh-alerts/n-sample000001", + "encoding": "binary", + "body": { + "title": "Attention requested", + "message": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC", + "priority": 4, + "tags": [ + "warning" + ], + "click": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "actions": [ + { + "action": "http", + "label": "Mark resolved", + "url": "https://ntfy.example.net/bh-replies", + "method": "POST", + "body": "bh1:preview-resolve", + "clear": true + }, + { + "action": "http", + "label": "Reject", + "url": "https://ntfy.example.net/bh-replies", + "method": "POST", + "body": "bh1:preview-reject", + "clear": true + }, + { + "action": "view", + "label": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "clear": false + } + ], + "filename": "screenshot.jpg" + }, + "headers": {}, + "file": { + "ref": "nimg-sample", + "name": "screenshot.jpg", + "content_type": "image/jpeg" + } + } + ] +} diff --git a/packages/core/test/goldens/notifications/ntfy/vault-confirm-act.json b/packages/core/test/goldens/notifications/ntfy/vault-confirm-act.json new file mode 100644 index 0000000..193eca1 --- /dev/null +++ b/packages/core/test/goldens/notifications/ntfy/vault-confirm-act.json @@ -0,0 +1,50 @@ +{ + "kind": "ntfy", + "variant": "vault-confirm-act", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "/", + "encoding": "json", + "body": { + "topic": "bh-alerts", + "title": "Vault fill awaiting confirm", + "message": "entry github — approve or deny the release\n\nEntry: github\nSession: checkout\nPage: https://github.com/login\nTool: vault_fill\nWaiting since: 14:13 UTC", + "priority": 4, + "tags": [ + "warning" + ], + "click": "https://bh.example.net/vault?tab=confirm", + "actions": [ + { + "action": "http", + "label": "Approve", + "url": "https://ntfy.example.net/bh-replies", + "method": "POST", + "body": "bh1:preview-approve", + "clear": true + }, + { + "action": "http", + "label": "Deny", + "url": "https://ntfy.example.net/bh-replies", + "method": "POST", + "body": "bh1:preview-deny", + "clear": true + }, + { + "action": "view", + "label": "Review", + "url": "https://bh.example.net/vault?tab=confirm", + "clear": false + } + ], + "markdown": false, + "sequence_id": "n-sample000001" + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/attention-act.json b/packages/core/test/goldens/notifications/telegram-classic/attention-act.json new file mode 100644 index 0000000..5cf1823 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/attention-act.json @@ -0,0 +1,45 @@ +{ + "kind": "telegram-classic", + "variant": "attention-act", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "style": "primary" + }, + { + "text": "Mark resolved", + "callback_data": "bh1:preview-resolve" + } + ], + [ + { + "text": "Reject", + "callback_data": "bh1:preview-reject", + "style": "danger" + } + ] + ] + }, + "text": "⚠️ Attention requested\nCAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/attention-counts.json b/packages/core/test/goldens/notifications/telegram-classic/attention-counts.json new file mode 100644 index 0000000..939f226 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/attention-counts.json @@ -0,0 +1,38 @@ +{ + "kind": "telegram-classic", + "variant": "attention-counts", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "style": "primary" + }, + { + "text": "Open in BrowserHive", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1" + } + ] + ] + }, + "text": "⚠️ Attention requested\nSession checkout", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/attention-image-act.json b/packages/core/test/goldens/notifications/telegram-classic/attention-image-act.json new file mode 100644 index 0000000..96afb8f --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/attention-image-act.json @@ -0,0 +1,46 @@ +{ + "kind": "telegram-classic", + "variant": "attention-image-act", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendPhoto", + "encoding": "multipart", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "style": "primary" + }, + { + "text": "Mark resolved", + "callback_data": "bh1:preview-resolve" + } + ], + [ + { + "text": "Reject", + "callback_data": "bh1:preview-reject", + "style": "danger" + } + ] + ] + }, + "caption": "⚠️ Attention requested\nCAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC" + }, + "headers": {}, + "file": { + "ref": "nimg-sample", + "name": "screenshot.jpg", + "content_type": "image/jpeg" + } + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/attention-image.json b/packages/core/test/goldens/notifications/telegram-classic/attention-image.json new file mode 100644 index 0000000..98f2753 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/attention-image.json @@ -0,0 +1,39 @@ +{ + "kind": "telegram-classic", + "variant": "attention-image", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendPhoto", + "encoding": "multipart", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "style": "primary" + }, + { + "text": "Open in BrowserHive", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1" + } + ] + ] + }, + "caption": "⚠️ Attention requested\nCAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC" + }, + "headers": {}, + "file": { + "ref": "nimg-sample", + "name": "screenshot.jpg", + "content_type": "image/jpeg" + } + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/attention-local.json b/packages/core/test/goldens/notifications/telegram-classic/attention-local.json new file mode 100644 index 0000000..241e39a --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/attention-local.json @@ -0,0 +1,23 @@ +{ + "kind": "telegram-classic", + "variant": "attention-local", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "text": "⚠️ Attention requested\nCAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\n\n🖥 Open on this computer\nTake over: http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1&takeover=1\nOpen in BrowserHive: http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/attention-resolved-edit.json b/packages/core/test/goldens/notifications/telegram-classic/attention-resolved-edit.json new file mode 100644 index 0000000..bf9b5d2 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/attention-resolved-edit.json @@ -0,0 +1,26 @@ +{ + "kind": "telegram-classic", + "variant": "attention-resolved-edit", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "editMessageText", + "encoding": "json", + "body": { + "chat_id": -1001234567890, + "message_id": 101, + "text": "✅ Attention requested\nResolved by admin after 2m 10s\n\n
    CAPTCHA on the checkout page: please solve it, then resume
    \n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC", + "parse_mode": "HTML", + "link_preview_options": { + "is_disabled": true + }, + "reply_markup": { + "inline_keyboard": [] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/attention-resolved-image-edit.json b/packages/core/test/goldens/notifications/telegram-classic/attention-resolved-image-edit.json new file mode 100644 index 0000000..e2e79f2 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/attention-resolved-image-edit.json @@ -0,0 +1,23 @@ +{ + "kind": "telegram-classic", + "variant": "attention-resolved-image-edit", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "editMessageCaption", + "encoding": "json", + "body": { + "chat_id": -1001234567890, + "message_id": 101, + "caption": "✅ Attention requested\nResolved by admin after 2m 10s\n\n
    CAPTCHA on the checkout page: please solve it, then resume
    \n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC", + "parse_mode": "HTML", + "reply_markup": { + "inline_keyboard": [] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/attention-resolved.json b/packages/core/test/goldens/notifications/telegram-classic/attention-resolved.json new file mode 100644 index 0000000..313da06 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/attention-resolved.json @@ -0,0 +1,23 @@ +{ + "kind": "telegram-classic", + "variant": "attention-resolved", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": true, + "text": "✅ Attention requested\nResolved by admin after 2m 10s\n\n
    CAPTCHA on the checkout page: please solve it, then resume
    \n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/attention.json b/packages/core/test/goldens/notifications/telegram-classic/attention.json new file mode 100644 index 0000000..5630890 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/attention.json @@ -0,0 +1,38 @@ +{ + "kind": "telegram-classic", + "variant": "attention", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "style": "primary" + }, + { + "text": "Open in BrowserHive", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1" + } + ] + ] + }, + "text": "⚠️ Attention requested\nCAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/crash.json b/packages/core/test/goldens/notifications/telegram-classic/crash.json new file mode 100644 index 0000000..df85b26 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/crash.json @@ -0,0 +1,34 @@ +{ + "kind": "telegram-classic", + "variant": "crash", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open session", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4", + "style": "primary" + } + ] + ] + }, + "text": "🔴 Session crashed\nreason: crash\n\nSession: checkout\nReason: crash\nClosed: 14:13 UTC", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/degraded.json b/packages/core/test/goldens/notifications/telegram-classic/degraded.json new file mode 100644 index 0000000..61ed576 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/degraded.json @@ -0,0 +1,34 @@ +{ + "kind": "telegram-classic", + "variant": "degraded", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open System", + "url": "https://bh.example.net/system", + "style": "primary" + } + ] + ] + }, + "text": "🔴 The retention sweep failed: database is locked\nRETENTION_FAILED\n\nCode: RETENTION_FAILED\nSince: 14:13 UTC", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/test-local.json b/packages/core/test/goldens/notifications/telegram-classic/test-local.json new file mode 100644 index 0000000..2a2a6d0 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/test-local.json @@ -0,0 +1,23 @@ +{ + "kind": "telegram-classic", + "variant": "test-local", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "text": "ℹ️ BrowserHive test message\nThis channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.\n\nSent: 14:13 UTC\nKind: test\n\n🖥 Open on this computer\nOpen dashboard: http://127.0.0.1:9876/notifications/channels", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/test.json b/packages/core/test/goldens/notifications/telegram-classic/test.json new file mode 100644 index 0000000..ba33e40 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/test.json @@ -0,0 +1,34 @@ +{ + "kind": "telegram-classic", + "variant": "test", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open dashboard", + "url": "https://bh.example.net/notifications/channels", + "style": "primary" + } + ] + ] + }, + "text": "ℹ️ BrowserHive test message\nThis channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.\n\nSent: 14:13 UTC\nKind: test", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/tool-errors.json b/packages/core/test/goldens/notifications/telegram-classic/tool-errors.json new file mode 100644 index 0000000..ebcfc5e --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/tool-errors.json @@ -0,0 +1,34 @@ +{ + "kind": "telegram-classic", + "variant": "tool-errors", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": true, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open errors", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?kinds=tool&errors_only=1", + "style": "primary" + } + ] + ] + }, + "text": "⚠️ checkout · 3 tool errors\nnavigate · NAVIGATION_TIMEOUT (30000 ms)\n\nSession: checkout\nErrors: 3\nLatest: navigate · NAVIGATION_TIMEOUT\nDuration: 30 s", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/vault-confirm-act.json b/packages/core/test/goldens/notifications/telegram-classic/vault-confirm-act.json new file mode 100644 index 0000000..b7e5aa4 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/vault-confirm-act.json @@ -0,0 +1,45 @@ +{ + "kind": "telegram-classic", + "variant": "vault-confirm-act", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Approve", + "callback_data": "bh1:preview-approve", + "style": "success" + }, + { + "text": "Deny", + "callback_data": "bh1:preview-deny", + "style": "danger" + } + ], + [ + { + "text": "Review", + "url": "https://bh.example.net/vault?tab=confirm" + } + ] + ] + }, + "text": "⚠️ Vault fill awaiting confirm\nentry github — approve or deny the release\n\nEntry: github\nSession: checkout\nPage: https://github.com/login\nTool: vault_fill\nWaiting since: 14:13 UTC", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/vault-confirm.json b/packages/core/test/goldens/notifications/telegram-classic/vault-confirm.json new file mode 100644 index 0000000..a91c27f --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/vault-confirm.json @@ -0,0 +1,34 @@ +{ + "kind": "telegram-classic", + "variant": "vault-confirm", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Review in BrowserHive", + "url": "https://bh.example.net/vault?tab=confirm", + "style": "primary" + } + ] + ] + }, + "text": "⚠️ Vault fill awaiting confirm\nentry github — approve or deny the release\n\nEntry: github\nSession: checkout\nPage: https://github.com/login\nTool: vault_fill\nWaiting since: 14:13 UTC", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/attention-act.json b/packages/core/test/goldens/notifications/telegram/attention-act.json new file mode 100644 index 0000000..a31310b --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/attention-act.json @@ -0,0 +1,44 @@ +{ + "kind": "telegram", + "variant": "attention-act", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendRichMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "rich_message": { + "html": "

    ⚠️ Attention requested

    CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen

    Modetakeover
    Sessioncheckout
    Pagehttps://shop.example.com/checkout/payment
    Toolclick
    Waiting since14:13 UTC
    ", + "skip_entity_detection": true + }, + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "style": "primary" + }, + { + "text": "Mark resolved", + "callback_data": "bh1:preview-resolve" + } + ], + [ + { + "text": "Reject", + "callback_data": "bh1:preview-reject", + "style": "danger" + } + ] + ] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/attention-classic-ref-edit.json b/packages/core/test/goldens/notifications/telegram/attention-classic-ref-edit.json new file mode 100644 index 0000000..5fe3159 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/attention-classic-ref-edit.json @@ -0,0 +1,26 @@ +{ + "kind": "telegram", + "variant": "attention-classic-ref-edit", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "editMessageText", + "encoding": "json", + "body": { + "chat_id": -1001234567890, + "message_id": 101, + "text": "✅ Attention requested\nResolved by admin after 2m 10s\n\n
    CAPTCHA on the checkout page: please solve it, then resume
    \n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC", + "parse_mode": "HTML", + "link_preview_options": { + "is_disabled": true + }, + "reply_markup": { + "inline_keyboard": [] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/attention-counts.json b/packages/core/test/goldens/notifications/telegram/attention-counts.json index 04d50c8..d3b3255 100644 --- a/packages/core/test/goldens/notifications/telegram/attention-counts.json +++ b/packages/core/test/goldens/notifications/telegram/attention-counts.json @@ -5,18 +5,22 @@ "requests": [ { "method": "POST", - "path": "sendMessage", + "path": "sendRichMessage", "encoding": "json", "body": { "chat_id": "-1001234567890", - "parse_mode": "HTML", + "rich_message": { + "html": "

    ⚠️ Attention requested

    Session checkout

    ", + "skip_entity_detection": true + }, "disable_notification": false, "reply_markup": { "inline_keyboard": [ [ { "text": "Take over", - "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1" + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "style": "primary" }, { "text": "Open in BrowserHive", @@ -24,10 +28,6 @@ } ] ] - }, - "text": "⚠️ Attention requested\nSession checkout", - "link_preview_options": { - "is_disabled": true } }, "headers": {}, diff --git a/packages/core/test/goldens/notifications/telegram/attention-image-act.json b/packages/core/test/goldens/notifications/telegram/attention-image-act.json new file mode 100644 index 0000000..fa18c75 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/attention-image-act.json @@ -0,0 +1,57 @@ +{ + "kind": "telegram", + "variant": "attention-image-act", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendRichMessage", + "encoding": "multipart", + "body": { + "chat_id": "-1001234567890", + "rich_message": { + "html": "

    ⚠️ Attention requested

    CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen

    Modetakeover
    Sessioncheckout
    Pagehttps://shop.example.com/checkout/payment
    Toolclick
    Waiting since14:13 UTC
    ", + "media": [ + { + "id": "shot", + "media": { + "type": "photo", + "media": "attach://shot" + } + } + ], + "skip_entity_detection": true + }, + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "style": "primary" + }, + { + "text": "Mark resolved", + "callback_data": "bh1:preview-resolve" + } + ], + [ + { + "text": "Reject", + "callback_data": "bh1:preview-reject", + "style": "danger" + } + ] + ] + } + }, + "headers": {}, + "file": { + "ref": "nimg-sample", + "name": "screenshot.jpg", + "content_type": "image/jpeg" + } + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/attention-image.json b/packages/core/test/goldens/notifications/telegram/attention-image.json index 7ab6a39..f185a0a 100644 --- a/packages/core/test/goldens/notifications/telegram/attention-image.json +++ b/packages/core/test/goldens/notifications/telegram/attention-image.json @@ -5,18 +5,31 @@ "requests": [ { "method": "POST", - "path": "sendPhoto", + "path": "sendRichMessage", "encoding": "multipart", "body": { "chat_id": "-1001234567890", - "parse_mode": "HTML", + "rich_message": { + "html": "

    ⚠️ Attention requested

    CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen

    Modetakeover
    Sessioncheckout
    Pagehttps://shop.example.com/checkout/payment
    Toolclick
    Waiting since14:13 UTC
    ", + "media": [ + { + "id": "shot", + "media": { + "type": "photo", + "media": "attach://shot" + } + } + ], + "skip_entity_detection": true + }, "disable_notification": false, "reply_markup": { "inline_keyboard": [ [ { "text": "Take over", - "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1" + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "style": "primary" }, { "text": "Open in BrowserHive", @@ -24,8 +37,7 @@ } ] ] - }, - "caption": "⚠️ Attention requested\nCAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC" + } }, "headers": {}, "file": { diff --git a/packages/core/test/goldens/notifications/telegram/attention-local.json b/packages/core/test/goldens/notifications/telegram/attention-local.json index bb1da53..1dd551f 100644 --- a/packages/core/test/goldens/notifications/telegram/attention-local.json +++ b/packages/core/test/goldens/notifications/telegram/attention-local.json @@ -5,16 +5,15 @@ "requests": [ { "method": "POST", - "path": "sendMessage", + "path": "sendRichMessage", "encoding": "json", "body": { "chat_id": "-1001234567890", - "parse_mode": "HTML", - "disable_notification": false, - "text": "⚠️ Attention requested\nCAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\n\n🖥 Open on this computer\nTake over: http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1&takeover=1\nOpen in BrowserHive: http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1", - "link_preview_options": { - "is_disabled": true - } + "rich_message": { + "html": "

    ⚠️ Attention requested

    CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen

    Modetakeover
    Sessioncheckout
    Pagehttps://shop.example.com/checkout/payment
    Toolclick
    Waiting since14:13 UTC

    🖥 Open on this computer
    Take over: http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1&takeover=1
    Open in BrowserHive: http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1

    ", + "skip_entity_detection": true + }, + "disable_notification": false }, "headers": {}, "file": null diff --git a/packages/core/test/goldens/notifications/telegram/attention-resolved-edit.json b/packages/core/test/goldens/notifications/telegram/attention-resolved-edit.json index 35ea9c3..97b29e1 100644 --- a/packages/core/test/goldens/notifications/telegram/attention-resolved-edit.json +++ b/packages/core/test/goldens/notifications/telegram/attention-resolved-edit.json @@ -10,10 +10,9 @@ "body": { "chat_id": -1001234567890, "message_id": 101, - "text": "✅ Attention requested\nResolved by admin after 2m 10s\n\n
    CAPTCHA on the checkout page: please solve it, then resume
    \n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC", - "parse_mode": "HTML", - "link_preview_options": { - "is_disabled": true + "rich_message": { + "html": "

    ✅ Attention requested

    Resolved by admin after 2m 10s

    CAPTCHA on the checkout page: please solve it, then resume
    Modetakeover
    Sessioncheckout
    Pagehttps://shop.example.com/checkout/payment
    Toolclick
    Waiting since14:13 UTC
    Outcomeresolved
    Settled14:15 UTC
    ", + "skip_entity_detection": true }, "reply_markup": { "inline_keyboard": [] diff --git a/packages/core/test/goldens/notifications/telegram/attention-resolved-image-edit.json b/packages/core/test/goldens/notifications/telegram/attention-resolved-image-edit.json index efe1fae..28636c5 100644 --- a/packages/core/test/goldens/notifications/telegram/attention-resolved-image-edit.json +++ b/packages/core/test/goldens/notifications/telegram/attention-resolved-image-edit.json @@ -5,13 +5,24 @@ "requests": [ { "method": "POST", - "path": "editMessageCaption", + "path": "editMessageText", "encoding": "json", "body": { "chat_id": -1001234567890, "message_id": 101, - "caption": "✅ Attention requested\nResolved by admin after 2m 10s\n\n
    CAPTCHA on the checkout page: please solve it, then resume
    \n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC", - "parse_mode": "HTML", + "rich_message": { + "html": "

    ✅ Attention requested

    Resolved by admin after 2m 10s

    CAPTCHA on the checkout page: please solve it, then resume
    Modetakeover
    Sessioncheckout
    Pagehttps://shop.example.com/checkout/payment
    Toolclick
    Waiting since14:13 UTC
    Outcomeresolved
    Settled14:15 UTC
    ", + "media": [ + { + "id": "shot", + "media": { + "type": "photo", + "media": "photo-file-1" + } + } + ], + "skip_entity_detection": true + }, "reply_markup": { "inline_keyboard": [] } diff --git a/packages/core/test/goldens/notifications/telegram/attention-resolved.json b/packages/core/test/goldens/notifications/telegram/attention-resolved.json index d4a09d9..4d7b037 100644 --- a/packages/core/test/goldens/notifications/telegram/attention-resolved.json +++ b/packages/core/test/goldens/notifications/telegram/attention-resolved.json @@ -5,16 +5,15 @@ "requests": [ { "method": "POST", - "path": "sendMessage", + "path": "sendRichMessage", "encoding": "json", "body": { "chat_id": "-1001234567890", - "parse_mode": "HTML", - "disable_notification": true, - "text": "✅ Attention requested\nResolved by admin after 2m 10s\n\n
    CAPTCHA on the checkout page: please solve it, then resume
    \n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC", - "link_preview_options": { - "is_disabled": true - } + "rich_message": { + "html": "

    ✅ Attention requested

    Resolved by admin after 2m 10s

    CAPTCHA on the checkout page: please solve it, then resume
    Modetakeover
    Sessioncheckout
    Pagehttps://shop.example.com/checkout/payment
    Toolclick
    Waiting since14:13 UTC
    Outcomeresolved
    Settled14:15 UTC
    ", + "skip_entity_detection": true + }, + "disable_notification": true }, "headers": {}, "file": null diff --git a/packages/core/test/goldens/notifications/telegram/attention.json b/packages/core/test/goldens/notifications/telegram/attention.json index 6fec891..42902e8 100644 --- a/packages/core/test/goldens/notifications/telegram/attention.json +++ b/packages/core/test/goldens/notifications/telegram/attention.json @@ -5,18 +5,22 @@ "requests": [ { "method": "POST", - "path": "sendMessage", + "path": "sendRichMessage", "encoding": "json", "body": { "chat_id": "-1001234567890", - "parse_mode": "HTML", + "rich_message": { + "html": "

    ⚠️ Attention requested

    CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen

    Modetakeover
    Sessioncheckout
    Pagehttps://shop.example.com/checkout/payment
    Toolclick
    Waiting since14:13 UTC
    ", + "skip_entity_detection": true + }, "disable_notification": false, "reply_markup": { "inline_keyboard": [ [ { "text": "Take over", - "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1" + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "style": "primary" }, { "text": "Open in BrowserHive", @@ -24,10 +28,6 @@ } ] ] - }, - "text": "⚠️ Attention requested\nCAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC", - "link_preview_options": { - "is_disabled": true } }, "headers": {}, diff --git a/packages/core/test/goldens/notifications/telegram/crash.json b/packages/core/test/goldens/notifications/telegram/crash.json index 68c7736..9745eca 100644 --- a/packages/core/test/goldens/notifications/telegram/crash.json +++ b/packages/core/test/goldens/notifications/telegram/crash.json @@ -5,25 +5,25 @@ "requests": [ { "method": "POST", - "path": "sendMessage", + "path": "sendRichMessage", "encoding": "json", "body": { "chat_id": "-1001234567890", - "parse_mode": "HTML", + "rich_message": { + "html": "

    🔴 Session crashed

    reason: crash

    Sessioncheckout
    Reasoncrash
    Closed14:13 UTC
    ", + "skip_entity_detection": true + }, "disable_notification": false, "reply_markup": { "inline_keyboard": [ [ { "text": "Open session", - "url": "https://bh.example.net/sessions/checkout-a1b2c3d4" + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4", + "style": "primary" } ] ] - }, - "text": "🔴 Session crashed\nreason: crash\n\nSession: checkout\nReason: crash\nClosed: 14:13 UTC", - "link_preview_options": { - "is_disabled": true } }, "headers": {}, diff --git a/packages/core/test/goldens/notifications/telegram/degraded.json b/packages/core/test/goldens/notifications/telegram/degraded.json index 0eda63c..74c6aa7 100644 --- a/packages/core/test/goldens/notifications/telegram/degraded.json +++ b/packages/core/test/goldens/notifications/telegram/degraded.json @@ -5,25 +5,25 @@ "requests": [ { "method": "POST", - "path": "sendMessage", + "path": "sendRichMessage", "encoding": "json", "body": { "chat_id": "-1001234567890", - "parse_mode": "HTML", + "rich_message": { + "html": "

    🔴 The retention sweep failed: database is locked

    RETENTION_FAILED

    CodeRETENTION_FAILED
    Since14:13 UTC
    ", + "skip_entity_detection": true + }, "disable_notification": false, "reply_markup": { "inline_keyboard": [ [ { "text": "Open System", - "url": "https://bh.example.net/system" + "url": "https://bh.example.net/system", + "style": "primary" } ] ] - }, - "text": "🔴 The retention sweep failed: database is locked\nRETENTION_FAILED\n\nCode: RETENTION_FAILED\nSince: 14:13 UTC", - "link_preview_options": { - "is_disabled": true } }, "headers": {}, diff --git a/packages/core/test/goldens/notifications/telegram/test-local.json b/packages/core/test/goldens/notifications/telegram/test-local.json index 0395b07..e0bb664 100644 --- a/packages/core/test/goldens/notifications/telegram/test-local.json +++ b/packages/core/test/goldens/notifications/telegram/test-local.json @@ -5,16 +5,15 @@ "requests": [ { "method": "POST", - "path": "sendMessage", + "path": "sendRichMessage", "encoding": "json", "body": { "chat_id": "-1001234567890", - "parse_mode": "HTML", - "disable_notification": false, - "text": "ℹ️ BrowserHive test message\nThis channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.\n\nSent: 14:13 UTC\nKind: test\n\n🖥 Open on this computer\nOpen dashboard: http://127.0.0.1:9876/notifications/channels", - "link_preview_options": { - "is_disabled": true - } + "rich_message": { + "html": "

    ℹ️ BrowserHive test message

    This channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.

    Sent14:13 UTC
    Kindtest

    🖥 Open on this computer
    Open dashboard: http://127.0.0.1:9876/notifications/channels

    ", + "skip_entity_detection": true + }, + "disable_notification": false }, "headers": {}, "file": null diff --git a/packages/core/test/goldens/notifications/telegram/test.json b/packages/core/test/goldens/notifications/telegram/test.json index 7b97a90..73d8d0d 100644 --- a/packages/core/test/goldens/notifications/telegram/test.json +++ b/packages/core/test/goldens/notifications/telegram/test.json @@ -5,25 +5,25 @@ "requests": [ { "method": "POST", - "path": "sendMessage", + "path": "sendRichMessage", "encoding": "json", "body": { "chat_id": "-1001234567890", - "parse_mode": "HTML", + "rich_message": { + "html": "

    ℹ️ BrowserHive test message

    This channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.

    Sent14:13 UTC
    Kindtest
    ", + "skip_entity_detection": true + }, "disable_notification": false, "reply_markup": { "inline_keyboard": [ [ { "text": "Open dashboard", - "url": "https://bh.example.net/notifications/channels" + "url": "https://bh.example.net/notifications/channels", + "style": "primary" } ] ] - }, - "text": "ℹ️ BrowserHive test message\nThis channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.\n\nSent: 14:13 UTC\nKind: test", - "link_preview_options": { - "is_disabled": true } }, "headers": {}, diff --git a/packages/core/test/goldens/notifications/telegram/tool-errors.json b/packages/core/test/goldens/notifications/telegram/tool-errors.json index 6cdc393..787c8ab 100644 --- a/packages/core/test/goldens/notifications/telegram/tool-errors.json +++ b/packages/core/test/goldens/notifications/telegram/tool-errors.json @@ -5,25 +5,25 @@ "requests": [ { "method": "POST", - "path": "sendMessage", + "path": "sendRichMessage", "encoding": "json", "body": { "chat_id": "-1001234567890", - "parse_mode": "HTML", + "rich_message": { + "html": "

    ⚠️ checkout · 3 tool errors

    navigate · NAVIGATION_TIMEOUT (30000 ms)

    Sessioncheckout
    Errors3
    Latestnavigate · NAVIGATION_TIMEOUT
    Duration30 s
    ", + "skip_entity_detection": true + }, "disable_notification": true, "reply_markup": { "inline_keyboard": [ [ { "text": "Open errors", - "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?kinds=tool&errors_only=1" + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?kinds=tool&errors_only=1", + "style": "primary" } ] ] - }, - "text": "⚠️ checkout · 3 tool errors\nnavigate · NAVIGATION_TIMEOUT (30000 ms)\n\nSession: checkout\nErrors: 3\nLatest: navigate · NAVIGATION_TIMEOUT\nDuration: 30 s", - "link_preview_options": { - "is_disabled": true } }, "headers": {}, diff --git a/packages/core/test/goldens/notifications/telegram/vault-confirm-act.json b/packages/core/test/goldens/notifications/telegram/vault-confirm-act.json new file mode 100644 index 0000000..b471bcb --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/vault-confirm-act.json @@ -0,0 +1,44 @@ +{ + "kind": "telegram", + "variant": "vault-confirm-act", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendRichMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "rich_message": { + "html": "

    ⚠️ Vault fill awaiting confirm

    entry github — approve or deny the release

    Entrygithub
    Sessioncheckout
    Pagehttps://github.com/login
    Toolvault_fill
    Waiting since14:13 UTC
    ", + "skip_entity_detection": true + }, + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Approve", + "callback_data": "bh1:preview-approve", + "style": "success" + }, + { + "text": "Deny", + "callback_data": "bh1:preview-deny", + "style": "danger" + } + ], + [ + { + "text": "Review", + "url": "https://bh.example.net/vault?tab=confirm" + } + ] + ] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/vault-confirm.json b/packages/core/test/goldens/notifications/telegram/vault-confirm.json index eab543f..5cc7199 100644 --- a/packages/core/test/goldens/notifications/telegram/vault-confirm.json +++ b/packages/core/test/goldens/notifications/telegram/vault-confirm.json @@ -5,25 +5,25 @@ "requests": [ { "method": "POST", - "path": "sendMessage", + "path": "sendRichMessage", "encoding": "json", "body": { "chat_id": "-1001234567890", - "parse_mode": "HTML", + "rich_message": { + "html": "

    ⚠️ Vault fill awaiting confirm

    entry github — approve or deny the release

    Entrygithub
    Sessioncheckout
    Pagehttps://github.com/login
    Toolvault_fill
    Waiting since14:13 UTC
    ", + "skip_entity_detection": true + }, "disable_notification": false, "reply_markup": { "inline_keyboard": [ [ { "text": "Review in BrowserHive", - "url": "https://bh.example.net/vault?tab=confirm" + "url": "https://bh.example.net/vault?tab=confirm", + "style": "primary" } ] ] - }, - "text": "⚠️ Vault fill awaiting confirm\nentry github — approve or deny the release\n\nEntry: github\nSession: checkout\nPage: https://github.com/login\nTool: vault_fill\nWaiting since: 14:13 UTC", - "link_preview_options": { - "is_disabled": true } }, "headers": {}, diff --git a/packages/core/test/goldens/notifications/webhook/attention-act.json b/packages/core/test/goldens/notifications/webhook/attention-act.json new file mode 100644 index 0000000..d4b5d25 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/attention-act.json @@ -0,0 +1,165 @@ +{ + "kind": "webhook", + "variant": "attention-act", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "take-over": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "resolve": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1", + "reject": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "attention:a-sample000001", + "kind": "attention.requested", + "category": "needs-you", + "severity": "warn", + "state": "open", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Attention requested", + "summary": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen", + "blocks": [ + { + "type": "quote", + "content": [ + { + "type": "text", + "text": "CAPTCHA on the checkout page: please solve it, then resume" + } + ], + "collapsible": true + }, + { + "type": "fields", + "items": [ + { + "label": "Mode", + "value": [ + { + "type": "text", + "text": "takeover" + } + ] + }, + { + "label": "Session", + "value": [ + { + "type": "link", + "text": "checkout", + "path": "/sessions/checkout-a1b2c3d4" + } + ] + }, + { + "label": "Page", + "value": [ + { + "type": "code", + "text": "https://shop.example.com/checkout/payment" + } + ] + }, + { + "label": "Tool", + "value": [ + { + "type": "code", + "text": "click" + } + ] + }, + { + "label": "Waiting since", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "take-over", + "label": "Take over", + "style": "primary", + "path": "/sessions/checkout-a1b2c3d4?live=1&takeover=1" + }, + { + "kind": "act", + "id": "resolve", + "label": "Mark resolved", + "style": "default", + "command": { + "op": "attention.resolve", + "args": { + "request_id": "a-sample000001", + "decision": "resolve" + } + }, + "confirm": null, + "fallback": { + "label": "Open in BrowserHive", + "path": "/sessions/checkout-a1b2c3d4?live=1" + } + }, + { + "kind": "act", + "id": "reject", + "label": "Reject", + "style": "danger", + "command": { + "op": "attention.resolve", + "args": { + "request_id": "a-sample000001", + "decision": "reject" + } + }, + "confirm": "Reject this request? The agent is told it was rejected.", + "fallback": { + "label": "Open in BrowserHive", + "path": "/sessions/checkout-a1b2c3d4?live=1" + } + } + ], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout", + "owner": "local", + "tool": "click", + "domain": "shop.example.com", + "request_id": "a-sample000001" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/attention-image-act.json b/packages/core/test/goldens/notifications/webhook/attention-image-act.json new file mode 100644 index 0000000..94cab15 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/attention-image-act.json @@ -0,0 +1,175 @@ +{ + "kind": "webhook", + "variant": "attention-image-act", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "take-over": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "resolve": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1", + "reject": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "attention:a-sample000001", + "kind": "attention.requested", + "category": "needs-you", + "severity": "warn", + "state": "open", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Attention requested", + "summary": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen", + "blocks": [ + { + "type": "quote", + "content": [ + { + "type": "text", + "text": "CAPTCHA on the checkout page: please solve it, then resume" + } + ], + "collapsible": true + }, + { + "type": "text", + "content": [ + { + "type": "link", + "text": "View screenshot", + "path": "/sessions/checkout-a1b2c3d4?live=1" + } + ] + }, + { + "type": "fields", + "items": [ + { + "label": "Mode", + "value": [ + { + "type": "text", + "text": "takeover" + } + ] + }, + { + "label": "Session", + "value": [ + { + "type": "link", + "text": "checkout", + "path": "/sessions/checkout-a1b2c3d4" + } + ] + }, + { + "label": "Page", + "value": [ + { + "type": "code", + "text": "https://shop.example.com/checkout/payment" + } + ] + }, + { + "label": "Tool", + "value": [ + { + "type": "code", + "text": "click" + } + ] + }, + { + "label": "Waiting since", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "take-over", + "label": "Take over", + "style": "primary", + "path": "/sessions/checkout-a1b2c3d4?live=1&takeover=1" + }, + { + "kind": "act", + "id": "resolve", + "label": "Mark resolved", + "style": "default", + "command": { + "op": "attention.resolve", + "args": { + "request_id": "a-sample000001", + "decision": "resolve" + } + }, + "confirm": null, + "fallback": { + "label": "Open in BrowserHive", + "path": "/sessions/checkout-a1b2c3d4?live=1" + } + }, + { + "kind": "act", + "id": "reject", + "label": "Reject", + "style": "danger", + "command": { + "op": "attention.resolve", + "args": { + "request_id": "a-sample000001", + "decision": "reject" + } + }, + "confirm": "Reject this request? The agent is told it was rejected.", + "fallback": { + "label": "Open in BrowserHive", + "path": "/sessions/checkout-a1b2c3d4?live=1" + } + } + ], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout", + "owner": "local", + "tool": "click", + "domain": "shop.example.com", + "request_id": "a-sample000001" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/vault-confirm-act.json b/packages/core/test/goldens/notifications/webhook/vault-confirm-act.json new file mode 100644 index 0000000..83fcc8a --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/vault-confirm-act.json @@ -0,0 +1,155 @@ +{ + "kind": "webhook", + "variant": "vault-confirm-act", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "approve": "https://bh.example.net/vault?tab=confirm", + "deny": "https://bh.example.net/vault?tab=confirm", + "review": "https://bh.example.net/vault?tab=confirm" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "vault:a-sample000001", + "kind": "vault.confirm", + "category": "needs-you", + "severity": "warn", + "state": "open", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Vault fill awaiting confirm", + "summary": "entry github — approve or deny the release", + "blocks": [ + { + "type": "fields", + "items": [ + { + "label": "Entry", + "value": [ + { + "type": "code", + "text": "github" + } + ] + }, + { + "label": "Session", + "value": [ + { + "type": "link", + "text": "checkout", + "path": "/sessions/checkout-a1b2c3d4" + } + ] + }, + { + "label": "Page", + "value": [ + { + "type": "code", + "text": "https://github.com/login" + } + ] + }, + { + "label": "Tool", + "value": [ + { + "type": "code", + "text": "vault_fill" + } + ] + }, + { + "label": "Waiting since", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + } + ] + } + ], + "actions": [ + { + "kind": "act", + "id": "approve", + "label": "Approve", + "style": "primary", + "command": { + "op": "vault.confirm.resolve", + "args": { + "request_id": "a-sample000001", + "decision": "approve" + } + }, + "confirm": null, + "fallback": { + "label": "Review in BrowserHive", + "path": "/vault?tab=confirm" + } + }, + { + "kind": "act", + "id": "deny", + "label": "Deny", + "style": "danger", + "command": { + "op": "vault.confirm.resolve", + "args": { + "request_id": "a-sample000001", + "decision": "deny" + } + }, + "confirm": "Deny this vault fill?", + "fallback": { + "label": "Review in BrowserHive", + "path": "/vault?tab=confirm" + } + }, + { + "kind": "open", + "id": "review", + "label": "Review", + "style": "default", + "path": "/vault?tab=confirm" + } + ], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout", + "owner": "local", + "tool": "vault_fill", + "domain": "github.com", + "request_id": "a-sample000001" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/helpers/fake-platforms.ts b/packages/core/test/helpers/fake-platforms.ts index 9dc44f7..600b89a 100644 --- a/packages/core/test/helpers/fake-platforms.ts +++ b/packages/core/test/helpers/fake-platforms.ts @@ -1,8 +1,8 @@ -/** @module test/helpers/fake-platforms — one `Bun.serve` faking the Telegram Bot API (`/tg`), Discord webhooks (`/api/webhooks`), an ntfy server (`/ntfy`) and a plain webhook receiver (`/hook`) (spec 09 §4). Every request is recorded; failures are scripted per route. */ +/** @module test/helpers/fake-platforms — one `Bun.serve` faking the Telegram Bot API (`/tg`, including Rich Messages, `getUpdates` with callback queries and `answerCallbackQuery`), Discord webhooks (`/api/webhooks`), the Discord bot REST API and a gateway subset (`/discord`), an ntfy server with streaming subscriptions (`/ntfy`) and a plain webhook receiver (`/hook`) (spec 09 §4). Every request is recorded; failures are scripted per route. */ /** A request the fakes received. */ export interface RecordedRequest { - readonly platform: 'telegram' | 'discord' | 'ntfy' | 'webhook'; + readonly platform: 'telegram' | 'discord' | 'discord-bot' | 'ntfy' | 'webhook'; readonly method: string; /** Path without the platform prefix (Telegram: the method name). */ readonly path: string; @@ -40,12 +40,36 @@ export type RouteKey = string; export interface FakeUpdate { readonly update_id: number; readonly message?: Record; + readonly callback_query?: Record; +} + +/** A gateway payload the fake received from a client (`op`, and `d` for Identify/Resume). */ +export interface GatewayFrame { + readonly op: number; + readonly d: unknown; +} + +/** One gateway client connection of the fake. */ +interface GatewayClient { + readonly ws: { send(data: string): void; close(code?: number, reason?: string): void }; + seq: number; } /** Low-entropy fake credentials (gitleaks scans every commit). */ export const FAKE_TG_TOKEN = `1234:${'a'.repeat(35)}`; /** Token part of the fake Discord webhook URL. */ export const FAKE_DISCORD_TOKEN = 'b'.repeat(24); +/** Low-entropy fake Discord bot token. */ +export const FAKE_DISCORD_BOT_TOKEN = 'c'.repeat(40); +/** Ids the Discord bot fake answers with. */ +export const FAKE_DISCORD = { + applicationId: '100000000000000001', + botId: '100000000000000001', + guildId: '200000000000000002', + channelId: '300000000000000003', + categoryId: '300000000000000009', + userId: '400000000000000004', +} as const; /** * The fakes. `start()` binds an ephemeral port; `stop()` releases it (hanging requests included). @@ -55,12 +79,39 @@ export class FakePlatforms { readonly updates: FakeUpdate[] = []; private readonly scripts = new Map(); private readonly ntfyMessages = new Map[]>(); - private server: ReturnType | undefined; + private readonly ntfySubscribers = new Map void>>(); + private server: ReturnType> | undefined; private counter = 100; + /** Frames gateway clients sent (heartbeats, Identify, Resume). */ + readonly gatewayFrames: GatewayFrame[] = []; + private readonly gatewayClients = new Set(); + /** Heartbeat interval the fake gateway announces in Hello. */ + gatewayHeartbeatMs = 45_000; + /** When false, the fake gateway stops acknowledging heartbeats (a zombie connection). */ + gatewayAcks = true; + /** Connections the fake gateway accepted. */ + gatewayConnections = 0; /** Starts the server. */ start(): this { - this.server = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => this.handle(req) }); + this.server = Bun.serve<{ gateway: true }, never>({ + port: 0, + hostname: '127.0.0.1', + fetch: (req, server) => { + if (new URL(req.url).pathname.startsWith('/discord/gateway')) { + if (server.upgrade(req, { data: { gateway: true } })) return undefined; + return new Response('upgrade failed', { status: 400 }); + } + return this.handle(req); + }, + websocket: { + open: (ws) => this.gatewayOpen(ws), + message: (ws, message) => this.gatewayMessage(ws, String(message)), + close: (ws) => { + for (const c of this.gatewayClients) if (c.ws === ws) this.gatewayClients.delete(c); + }, + }, + }); return this; } @@ -94,6 +145,108 @@ export class FakePlatforms { return `${this.url}/hook/bh`; } + /** Bot REST base for the Discord bot fake (`apiBase`). */ + get discordApi(): string { + return `${this.url}/discord/api/v10`; + } + + /** Clients connected to the fake gateway. */ + get gatewayClientCount(): number { + return this.gatewayClients.size; + } + + /** Sends a dispatch (`op 0`) to every gateway client. */ + gatewayDispatch(t: string, d: unknown): void { + for (const c of this.gatewayClients) { + c.seq += 1; + c.ws.send(JSON.stringify({ op: 0, t, s: c.seq, d })); + } + } + + /** Sends a raw payload to every gateway client (Reconnect, Invalid Session). */ + gatewaySend(payload: Record): void { + for (const c of this.gatewayClients) c.ws.send(JSON.stringify(payload)); + } + + /** Drops every gateway connection with `code`. */ + gatewayDrop(code = 4000): void { + for (const c of this.gatewayClients) c.ws.close(code, 'dropped'); + this.gatewayClients.clear(); + } + + /** + * A button press: sends `INTERACTION_CREATE` for a message component with `custom_id`. + * + * @returns The interaction id (its callback lands on `/interactions///callback`). + */ + discordPress(customId: string, options: { userId?: string; channelId?: string } = {}): string { + const id = String(this.next()); + this.gatewayDispatch('INTERACTION_CREATE', { + id, + application_id: FAKE_DISCORD.applicationId, + type: 3, + token: `itoken${id}`, + channel_id: options.channelId ?? FAKE_DISCORD.channelId, + guild_id: FAKE_DISCORD.guildId, + member: { + user: { + id: options.userId ?? FAKE_DISCORD.userId, + username: 'operator', + global_name: 'Op Erator', + }, + }, + data: { custom_id: customId, component_type: 2 }, + }); + return id; + } + + /** Publishes to an ntfy topic as a phone's `http` action does (`POST /`, text body). */ + ntfyPost(topic: string, text: string): Record { + const message = { + id: `m${this.next()}`, + time: Math.floor(Date.now() / 1000), + event: 'message', + topic, + message: text, + }; + this.push(topic, message); + return message; + } + + private gatewayOpen(ws: GatewayClient['ws']): void { + this.gatewayConnections += 1; + const client: GatewayClient = { ws, seq: 0 }; + this.gatewayClients.add(client); + ws.send(JSON.stringify({ op: 10, d: { heartbeat_interval: this.gatewayHeartbeatMs } })); + } + + private gatewayMessage(ws: GatewayClient['ws'], text: string): void { + const frame = JSON.parse(text) as GatewayFrame; + this.gatewayFrames.push(frame); + const client = [...this.gatewayClients].find((c) => c.ws === ws); + if (client === undefined) return; + if (frame.op === 1 && this.gatewayAcks) ws.send(JSON.stringify({ op: 11 })); + if (frame.op === 2) { + client.seq += 1; + ws.send( + JSON.stringify({ + op: 0, + t: 'READY', + s: client.seq, + d: { + session_id: `session${this.gatewayConnections}`, + resume_gateway_url: `ws://127.0.0.1:${this.server?.port ?? 0}/discord/gateway`, + user: { id: FAKE_DISCORD.botId, username: 'bh_bot' }, + }, + }), + ); + } + if (frame.op === 6) { + client.seq = Number((frame.d as { seq?: number }).seq ?? 0) + 1; + ws.send(JSON.stringify({ op: 0, t: 'RESUMED', s: client.seq, d: {} })); + } + } + /** Queues answers for the next calls of `route` (after them, the default success). */ script(route: RouteKey, ...answers: ScriptedAnswer[]): void { this.scripts.set(route, [...(this.scripts.get(route) ?? []), ...answers]); @@ -186,6 +339,9 @@ export class FakePlatforms { const path = url.pathname; if (path.startsWith('/tg/bot')) return this.telegram(req, path); if (path.startsWith('/api/webhooks/')) return this.discord(req, path); + if (path.startsWith('/discord/api/v10/')) { + return this.discordBot(req, path.slice('/discord/api/v10'.length)); + } if (path.startsWith('/ntfy')) return this.ntfy(req, path.slice('/ntfy'.length) || '/'); if (path.startsWith('/hook')) { await this.record(req, 'webhook', path); @@ -219,6 +375,21 @@ export class FakePlatforms { ok: true, result: { message_id: this.next(), chat, text: body['text'] }, }); + case 'sendRichMessage': { + const rich = parseRich(body['rich_message']); + return Response.json({ + ok: true, + result: { + message_id: this.next(), + chat, + rich_message: { + blocks: rich?.media === undefined ? [] : [{ type: 'photo', photo: RICH_PHOTOS }], + }, + }, + }); + } + case 'answerCallbackQuery': + return Response.json({ ok: true, result: true }); case 'sendPhoto': return Response.json({ ok: true, @@ -229,11 +400,21 @@ export class FakePlatforms { }, }); case 'editMessageText': - case 'editMessageCaption': + case 'editMessageCaption': { + const rich = parseRich(body['rich_message']); return Response.json({ ok: true, - result: { message_id: Number(body['message_id']), chat }, + result: { + message_id: Number(body['message_id']), + chat, + ...(rich !== null && { + rich_message: { + blocks: rich.media === undefined ? [] : [{ type: 'photo', photo: RICH_PHOTOS }], + }, + }), + }, }); + } case 'deleteMessage': return Response.json({ ok: true, result: true }); default: @@ -274,9 +455,45 @@ export class FakePlatforms { const segments = path.split('/').filter(Boolean); if (req.method === 'GET') { const topic = segments[0] ?? ''; - const lines = this.ntfyTopic(topic).map((m) => JSON.stringify(m)); - return new Response(lines.join('\n'), { - headers: { 'content-type': 'application/x-ndjson' }, + const since = recorded.query['since']; + const all = this.ntfyTopic(topic); + const index = since === undefined ? -1 : all.findIndex((m) => m['id'] === since); + const cached = + since === undefined + ? all + : index >= 0 + ? all.slice(index + 1) + : all.filter((m) => Number(m['time'] ?? 0) >= Number(since)); + const lines = cached.map((m) => JSON.stringify(m)); + if (recorded.query['poll'] === '1') { + return new Response(lines.join('\n'), { + headers: { 'content-type': 'application/x-ndjson' }, + }); + } + return this.answer('ntfy:SUBSCRIBE', () => { + const encoder = new TextEncoder(); + let push: ((line: string) => void) | undefined; + const stream = new ReadableStream({ + start: (controller) => { + const open = JSON.stringify({ id: `o${this.next()}`, event: 'open', topic }); + controller.enqueue(encoder.encode(`${open}\n`)); + for (const line of lines) controller.enqueue(encoder.encode(`${line}\n`)); + push = (line) => { + try { + controller.enqueue(encoder.encode(`${line}\n`)); + } catch { + // closed + } + }; + const set = this.ntfySubscribers.get(topic) ?? new Set(); + set.add(push); + this.ntfySubscribers.set(topic, set); + }, + cancel: () => { + if (push !== undefined) this.ntfySubscribers.get(topic)?.delete(push); + }, + }); + return new Response(stream, { headers: { 'content-type': 'application/x-ndjson' } }); }); } return this.answer(`ntfy:${req.method}`, () => { @@ -315,6 +532,82 @@ export class FakePlatforms { } private push(topic: string, message: Record): void { - this.ntfyMessages.set(topic, [...this.ntfyTopic(topic), message]); + const stamped = { time: Math.floor(Date.now() / 1000), ...message }; + this.ntfyMessages.set(topic, [...this.ntfyTopic(topic), stamped]); + for (const push of this.ntfySubscribers.get(topic) ?? []) push(JSON.stringify(stamped)); + } + + /** Drops every open ntfy subscription (a lost connection). */ + ntfyDrop(): void { + this.ntfySubscribers.clear(); + } + + private async discordBot(req: Request, path: string): Promise { + const recorded = await this.record(req, 'discord-bot', path); + const segments = path.split('/').filter(Boolean); + return this.answer(`discord-bot:${req.method} ${segments[0] ?? ''}`, () => { + const ids = FAKE_DISCORD; + if (path === '/gateway/bot') { + return Response.json({ + url: `ws://127.0.0.1:${this.server?.port ?? 0}/discord/gateway`, + shards: 1, + }); + } + if (path === '/users/@me') return Response.json({ id: ids.botId, username: 'bh_bot' }); + if (path === '/applications/@me') return Response.json({ id: ids.applicationId }); + if (path === '/users/@me/guilds') return Response.json([{ id: ids.guildId, name: 'Home' }]); + if (segments[0] === 'guilds' && segments[2] === 'channels') { + return Response.json([ + { id: ids.categoryId, type: 4, name: 'Alerts', position: 1 }, + { + id: ids.channelId, + type: 0, + name: 'browserhive', + parent_id: ids.categoryId, + position: 2, + }, + { id: '300000000000000005', type: 2, name: 'Voice', position: 3 }, + { id: '300000000000000006', type: 5, name: 'news', position: 0 }, + ]); + } + if (segments[0] === 'interactions') return new Response(null, { status: 204 }); + if (segments[0] === 'webhooks') return Response.json({ id: 'followup' }); + if (segments[0] === 'channels' && segments[2] === 'messages') { + if (req.method === 'DELETE') return new Response(null, { status: 204 }); + const id = segments[3] ?? String(this.next()); + const attachments = recorded.files.map((f, i) => ({ id: `90${i}${id}`, filename: f.name })); + const kept = + (recorded.json as { attachments?: { id: string | number }[] } | null)?.attachments ?? []; + return Response.json({ + id, + channel_id: segments[1], + attachments: [ + ...kept + .filter((a) => typeof a.id === 'string') + .map((a) => ({ id: a.id, filename: 'screenshot.jpg' })), + ...attachments, + ], + }); + } + return Response.json({ message: 'Unknown', code: 0 }, { status: 404 }); + }); + } +} + +/** Photo sizes a sent Rich Message reports (the largest `file_id` is re-used by edits). */ +const RICH_PHOTOS = [ + { file_id: 'rp-small', width: 320, height: 180 }, + { file_id: 'rp-large', width: 1280, height: 720 }, +]; + +/** `rich_message` of a JSON body, or of a multipart field (a JSON string). */ +function parseRich(value: unknown): { html?: string; media?: unknown } | null { + if (typeof value === 'string') { + try { + return JSON.parse(value) as { html?: string; media?: unknown }; + } catch { + return null; + } } + return value !== null && typeof value === 'object' ? (value as { html?: string }) : null; } diff --git a/packages/core/test/helpers/http-kit.ts b/packages/core/test/helpers/http-kit.ts index fad528f..d957d54 100644 --- a/packages/core/test/helpers/http-kit.ts +++ b/packages/core/test/helpers/http-kit.ts @@ -46,7 +46,10 @@ import { createVaultRepos } from './in-memory-vault-repos.ts'; import { RecordingEventBus } from './recording-event-bus.ts'; /** Environment the channel service sees in the HTTP suites (names only matter). */ -export const CHANNEL_ENV: Readonly> = { BH_TELEGRAM_TOKEN: 'a'.repeat(40) }; +export const CHANNEL_ENV: Readonly> = { + BH_TELEGRAM_TOKEN: 'a'.repeat(40), + BH_DISCORD_BOT_TOKEN: 'c'.repeat(40), +}; /** Operator password used by every suite. */ export const PASSWORD = 'correct horse battery'; @@ -167,6 +170,19 @@ export async function createHttpKit(options: HttpKitOptions = {}) { botUsername: async () => 'bh_test_bot', waitForStart: async () => null, }, + discord: { + bot: async () => ({ + applicationId: '100000000000000001', + botId: '100000000000000001', + username: 'bh_bot', + guilds: [{ id: '200000000000000002', name: 'Home' }], + }), + channels: async () => [ + { id: '300000000000000003', name: 'browserhive', type: 'text', category: 'Alerts' }, + ], + claim: async () => null, + }, + connection: () => null, }); const publicUrl = new PublicUrlChecker({ publicUrl: undefined, diff --git a/packages/core/test/helpers/http-route-cases.ts b/packages/core/test/helpers/http-route-cases.ts index 9a6b593..772a09f 100644 --- a/packages/core/test/helpers/http-route-cases.ts +++ b/packages/core/test/helpers/http-route-cases.ts @@ -67,6 +67,15 @@ async function telegramConnect(ctx: CaseContext): Promise { ctx.state['connect'] = body.connect_id ?? 'placeholder01'; } +async function discordConnect(ctx: CaseContext): Promise { + const response = await ctx.kit.request('POST', api('/channels/discord/connect'), { + cookie: ctx.cookie, + body: { token_env: 'BH_DISCORD_BOT_TOKEN', channel_id: '300000000000000003' }, + }); + const body = (await response.json()) as { connect_id?: string }; + ctx.state['discordConnect'] = body.connect_id ?? 'placeholder01'; +} + async function liveSession(ctx: CaseContext): Promise { const session = await ctx.kit.sessions.create({ slug: 'live' }, { subject: 'admin' }); ctx.state['live'] = session.id; @@ -743,6 +752,58 @@ export const ROUTE_CASES: readonly RouteCase[] = [ }, invalid: { path: api('/channels/telegram/connect/x!') }, }, + { + operationId: 'getDiscordBot', + success: { + method: 'POST', + path: api('/channels/discord/bot'), + body: { token_env: 'BH_DISCORD_BOT_TOKEN' }, + status: 200, + }, + invalid: { method: 'POST', path: api('/channels/discord/bot'), body: { token_env: 'x y' } }, + }, + { + operationId: 'listDiscordChannels', + success: { + method: 'POST', + path: api('/channels/discord/channels'), + body: { token_env: 'BH_DISCORD_BOT_TOKEN', guild_id: '200000000000000002' }, + status: 200, + }, + invalid: { + method: 'POST', + path: api('/channels/discord/channels'), + body: { token_env: 'BH_DISCORD_BOT_TOKEN', guild_id: 'home' }, + }, + }, + { + operationId: 'startDiscordConnect', + success: { + method: 'POST', + path: api('/channels/discord/connect'), + body: { token_env: 'BH_DISCORD_BOT_TOKEN', channel_id: '300000000000000003' }, + status: 200, + }, + invalid: { + method: 'POST', + path: api('/channels/discord/connect'), + body: { token_env: 'BH_DISCORD_BOT_TOKEN' }, + }, + }, + { + operationId: 'getDiscordConnect', + setup: discordConnect, + success: { + path: (ctx) => api(`/channels/discord/connect/${ctx.state['discordConnect'] ?? ''}`), + status: 200, + }, + invalid: { path: api('/channels/discord/connect/x!') }, + }, + { + operationId: 'listChannelActions', + success: { path: api('/channels/actions?outcome=done,not_allowed'), status: 200 }, + invalid: { path: api('/channels/actions?outcome=perhaps') }, + }, { operationId: 'getChannel', setup: webhookChannel, diff --git a/packages/core/test/helpers/in-memory-action-repos.ts b/packages/core/test/helpers/in-memory-action-repos.ts new file mode 100644 index 0000000..4a38a48 --- /dev/null +++ b/packages/core/test/helpers/in-memory-action-repos.ts @@ -0,0 +1,107 @@ +/** @module test/helpers/in-memory-action-repos — Map-backed act-button token, press audit and cursor repositories; the conformance suite runs them beside the SQLite ones. */ + +import type { + NewNotificationAction, + NotificationActionListQuery, + NotificationActionRecord, + NotificationActionRepository, + NotificationActionTokenRecord, + NotificationActionTokenRepository, + NotificationCursorRepository, +} from '../../src/ports/persistence/notification-actions.ts'; + +/** `notification_action_tokens` in memory. */ +export class InMemoryNotificationActionTokenRepository + implements NotificationActionTokenRepository +{ + readonly rows = new Map(); + + async insert(rows: readonly NotificationActionTokenRecord[]): Promise { + for (const row of rows) { + if (this.rows.has(row.tokenHash)) throw new Error('UNIQUE constraint failed: token_hash'); + } + for (const row of rows) this.rows.set(row.tokenHash, row); + } + + async get(tokenHash: string): Promise { + return this.rows.get(tokenHash) ?? null; + } + + async claim(tokenHash: string, at: number): Promise { + const row = this.rows.get(tokenHash); + if (row === undefined || row.usedAt !== null) return false; + this.rows.set(tokenHash, { ...row, usedAt: at }); + return true; + } + + async prune(before: number): Promise { + let n = 0; + for (const [hash, row] of this.rows) { + if (row.expiresAt < before) { + this.rows.delete(hash); + n++; + } + } + return n; + } + + /** Drops every token of a channel (the FK cascade). */ + removeChannel(channelId: string): void { + for (const [hash, row] of this.rows) if (row.channelId === channelId) this.rows.delete(hash); + } +} + +/** `notification_actions` in memory. */ +export class InMemoryNotificationActionRepository implements NotificationActionRepository { + readonly rows: NotificationActionRecord[] = []; + private nextSeq = 1; + + async insert(row: NewNotificationAction): Promise { + const stored = { ...row, seq: this.nextSeq++ }; + this.rows.push(stored); + return stored; + } + + async get(seq: number): Promise { + return this.rows.find((r) => r.seq === seq) ?? null; + } + + async list(query: NotificationActionListQuery): Promise { + return this.rows + .filter( + (r) => + (query.channelId === undefined || r.channelId === query.channelId) && + (query.notificationId === undefined || r.notificationId === query.notificationId) && + (query.outcomes === undefined || + query.outcomes.length === 0 || + query.outcomes.includes(r.outcome)) && + (query.beforeSeq === undefined || r.seq < query.beforeSeq), + ) + .sort((a, b) => b.seq - a.seq) + .slice(0, query.limit ?? 50); + } + + async prune(before: number): Promise { + const keep = this.rows.filter((r) => r.at >= before); + const n = this.rows.length - keep.length; + this.rows.splice(0, this.rows.length, ...keep); + return n; + } +} + +/** `notification_cursors` in memory. */ +export class InMemoryNotificationCursorRepository implements NotificationCursorRepository { + readonly rows = new Map(); + + async get(key: string): Promise { + return this.rows.get(key)?.value ?? null; + } + + async set(key: string, value: string, at: number): Promise { + this.rows.set(key, { value, updatedAt: at }); + } + + async remove(key: string): Promise { + this.rows.delete(key); + } +} diff --git a/packages/core/test/helpers/in-memory-repos.ts b/packages/core/test/helpers/in-memory-repos.ts index 292c7b6..d787b7f 100644 --- a/packages/core/test/helpers/in-memory-repos.ts +++ b/packages/core/test/helpers/in-memory-repos.ts @@ -22,6 +22,11 @@ import type { } from '../../src/ports/persistence/sessions.ts'; import type { Repositories, UnitOfWork } from '../../src/ports/persistence/unit-of-work.ts'; import type { WriteJob, WriteQueue } from '../../src/ports/persistence/write-queue.ts'; +import { + InMemoryNotificationActionRepository, + InMemoryNotificationActionTokenRepository, + InMemoryNotificationCursorRepository, +} from './in-memory-action-repos.ts'; import { InMemoryNotificationChannelMessageRepository, InMemoryNotificationChannelRepository, @@ -294,9 +299,13 @@ export class InMemoryRepositories implements Repositories { readonly notificationChannelMessages = new InMemoryNotificationChannelMessageRepository( this.notificationDeliveries, ); + readonly notificationActionTokens = new InMemoryNotificationActionTokenRepository(); + readonly notificationActions = new InMemoryNotificationActionRepository(); + readonly notificationCursors = new InMemoryNotificationCursorRepository(); readonly notificationChannels = new InMemoryNotificationChannelRepository([ this.notificationDeliveries, this.notificationChannelMessages, + this.notificationActionTokens, ]); readonly operatorRequests = notImplemented('operatorRequests'); readonly operatorActions = notImplemented('operatorActions'); diff --git a/packages/core/test/integration/notifications/ntfy-live.test.ts b/packages/core/test/integration/notifications/ntfy-live.test.ts index 5e00589..e489c27 100644 --- a/packages/core/test/integration/notifications/ntfy-live.test.ts +++ b/packages/core/test/integration/notifications/ntfy-live.test.ts @@ -1,8 +1,14 @@ -/** @module test/integration/notifications/ntfy-live.test — the ntfy adapter against a real ntfy server (spec 09 §3.2): publish, read back, attachment upload, replace by sequence id, delete. Runs only when `BHDEV_NTFY_URL` points at a server (CI starts `binwiederhier/ntfy` in the `ntfy` job); skipped otherwise. */ +/** @module test/integration/notifications/ntfy-live.test — the ntfy adapter against a real ntfy server (spec 09 §3.2): publish, read back, attachment upload, replace by sequence id, delete, and the two-way reply topic (D-42): `http` actions that post to the server's own topic B, the streaming subscription that receives them, and the catch-up with `since=` after a restart. Runs only when `BHDEV_NTFY_URL` points at a server (CI starts `binwiederhier/ntfy` in the `ntfy` job); skipped otherwise. */ import { describe, expect, it } from 'bun:test'; import { randomBytes } from 'node:crypto'; -import { createNtfyChannel, NTFY_CAPABILITIES } from '../../../src/infra/notifications/index.ts'; +import { + type CursorStore, + createNtfyChannel, + createNtfyReplySource, + NTFY_CAPABILITIES, +} from '../../../src/infra/notifications/index.ts'; +import type { PressEvent } from '../../../src/ports/notification-channel.ts'; import { delivery, platformRecord, SAMPLE_IMAGES } from '../../notifications/helpers.ts'; const SERVER = process.env['BHDEV_NTFY_URL']; @@ -80,4 +86,73 @@ describe.skipIf(SERVER === undefined)('ntfy adapter against a real server', () = expect(event?.title).toBe('Vault fill awaiting confirm'); expect((event?.actions ?? []).length).toBeLessThanOrEqual(3); }); + + it('answers through the reply topic: http actions post to topic B, the subscription receives, since= catches up', async () => { + const server = (SERVER ?? '').replace(/\/+$/, ''); + const topic = `bh-ci-${randomBytes(6).toString('hex')}`; + const reply = `bh-ci-${randomBytes(8).toString('hex')}`; + const channel = createNtfyChannel( + platformRecord('ntfy', { + target: { server, topic, reply_topic: reply }, + rules: { act_buttons: true }, + }), + { token: null, topic: null, images: SAMPLE_IMAGES }, + ); + const d = delivery('attention', channel.capabilities); + await channel.send({ + ...d, + actTokens: new Map([ + ['resolve', 'bh1:AAAAAAAAAAA'], + ['reject', 'bh1:BBBBBBBBBBB'], + ]), + }); + const [sent] = await poll(server, topic); + const actions = (sent?.actions ?? []) as unknown as { + action: string; + url: string; + body?: string; + }[]; + expect(actions.filter((a) => a.action === 'http').map((a) => [a.url, a.body])).toEqual([ + [`${server}/${reply}`, 'bh1:AAAAAAAAAAA'], + [`${server}/${reply}`, 'bh1:BBBBBBBBBBB'], + ]); + const map = new Map(); + const cursors: CursorStore = { + get: async (k) => map.get(k) ?? null, + set: async (k, v) => { + map.set(k, v); + }, + }; + const presses: PressEvent[] = []; + const source = createNtfyReplySource({ + server, + topic: reply, + token: null, + cursorKey: 'ntfy:ci', + cursors, + }); + const handler = async (p: PressEvent) => { + presses.push(p); + return { outcome: 'done', text: 'ok', refused: false }; + }; + let stop = source.listen(handler, () => undefined); + const wait = async (n: number) => { + const deadline = Date.now() + 10_000; + while (presses.length < n && Date.now() < deadline) await Bun.sleep(50); + }; + await Bun.sleep(500); + // What the phone's http action does when the button is tapped. + const tapped = actions.find((a) => a.action === 'http'); + await fetch(tapped?.url ?? '', { method: 'POST', body: tapped?.body ?? '' }); + await wait(1); + expect(presses.map((p) => p.token)).toEqual(['AAAAAAAAAAA']); + // BrowserHive stopped: a tap while it is down is caught up with since=. + stop(); + await fetch(`${server}/${reply}`, { method: 'POST', body: 'bh1:BBBBBBBBBBB' }); + await Bun.sleep(300); + stop = source.listen(handler, () => undefined); + await wait(2); + expect(presses.map((p) => p.token)).toEqual(['AAAAAAAAAAA', 'BBBBBBBBBBB']); + stop(); + }); }); diff --git a/packages/core/test/notifications/act-buttons.sqlite.test.ts b/packages/core/test/notifications/act-buttons.sqlite.test.ts new file mode 100644 index 0000000..1ad15c2 --- /dev/null +++ b/packages/core/test/notifications/act-buttons.sqlite.test.ts @@ -0,0 +1,302 @@ +/** @module test/notifications/act-buttons.sqlite.test — the whole act-button path per platform on SQLite against the fakes (spec 09, 03 §9.6, D-41, D-42): bus event → outbox → send with minted tokens → a press arrives over the platform's listener → the command runs as the chat actor → the request's revision → a silent edit that removes the buttons and names who answered; the press is audited once and a second press is refused. */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import type { DomainEvents } from '../../src/app/events/catalog.ts'; +import { NotificationActionListeners } from '../../src/app/notifications/action-listeners.ts'; +import { + type ActionExecutor, + NotificationActionService, +} from '../../src/app/notifications/actions.ts'; +import { ChannelRegistry } from '../../src/app/notifications/channel-registry.ts'; +import { NotificationService } from '../../src/app/notifications/notification-service.ts'; +import { NotificationOutbox } from '../../src/app/notifications/outbox.ts'; +import { attentionCreated, attentionResolved } from '../../src/app/notifications/test-fixtures.ts'; +import { sha256Hex } from '../../src/domain/auth/digest.ts'; +import { + type CursorStore, + channelFactories, + DiscordGatewayHub, + TelegramUpdatesHub, +} from '../../src/infra/notifications/index.ts'; +import type { NotificationChannelRecord } from '../../src/ports/persistence/records.ts'; +import { CollectingLogger } from '../helpers/collecting-logger.ts'; +import { FakeIdGenerator } from '../helpers/fake-id-generator.ts'; +import { + FAKE_DISCORD, + FAKE_DISCORD_BOT_TOKEN, + FAKE_TG_TOKEN, + FakePlatforms, +} from '../helpers/fake-platforms.ts'; +import { RecordingEventBus } from '../helpers/recording-event-bus.ts'; +import { sessionRecord } from '../persistence/helpers.ts'; +import { openMemory, type TestDb } from '../persistence/setup.ts'; +import { PUBLIC_LINKS, platformRecord, SAMPLE_IMAGES } from './helpers.ts'; + +let t: TestDb; +let fakes: FakePlatforms; +let stops: (() => void)[] = []; +beforeEach(async () => { + t = await openMemory(); + await t.repos.sessions.insert(sessionRecord()); + fakes = new FakePlatforms().start(); + stops = []; +}); +afterEach(async () => { + for (const stop of stops) stop(); + await fakes.stop(); + await t.close(); +}); + +async function until(check: () => boolean, ms = 4_000): Promise { + const deadline = Date.now() + ms; + while (!check()) { + if (Date.now() > deadline) throw new Error('timed out waiting'); + await Bun.sleep(5); + } +} + +async function wire(record: NotificationChannelRecord, env: Record) { + const bus = new RecordingEventBus(); + const logger = new CollectingLogger(); + const ids = new FakeIdGenerator(); + const cursors: CursorStore = { + get: (key) => t.repos.notificationCursors.get(key), + set: (key, value) => t.repos.notificationCursors.set(key, value, t.clock.now()), + }; + const telegramUpdates = new TelegramUpdatesHub({ + apiBase: fakes.telegramBase, + cursors, + pollSeconds: 1, + lingerMs: 0, + }); + const discordGateway = new DiscordGatewayHub({ + apiBase: fakes.discordApi, + random: () => 0.5, + lingerMs: 0, + }); + stops.push( + () => telegramUpdates.stop(), + () => discordGateway.stop(), + ); + await t.repos.notificationChannels.upsert(record); + const registry = new ChannelRegistry({ + repo: t.repos.notificationChannels, + clock: t.clock, + ids, + logger, + factories: channelFactories({ + images: SAMPLE_IMAGES, + apiBases: { telegram: fakes.telegramBase, discord: fakes.discordApi }, + telegramUpdates, + discordGateway, + cursors, + }), + env: (name) => env[name], + }); + await registry.load(); + let service: NotificationService | undefined; + const actors: string[] = []; + const executors = new Map([ + [ + 'attention.resolve', + { + scope: 'attention:resolve', + async run(args, actor) { + actors.push(actor); + // What the broker publishes when the operator resolves the request. + const event = attentionResolved(String(args['request_id']), 'resolved'); + const payload = event.payload as DomainEvents['attention.resolved']; + await service?.produce({ + ...event, + payload: { ...payload, request: { ...payload.request, resolved_by: actor } }, + } as typeof event); + return 'Marked resolved.'; + }, + }, + ], + ]); + const actions = new NotificationActionService({ + repos: t.repos, + registry, + clock: t.clock, + ids, + logger, + executors, + bus, + }); + const outbox = new NotificationOutbox({ + uow: t.uow, + repos: t.repos, + registry, + links: PUBLIC_LINKS, + clock: t.clock, + logger, + bus, + actions, + }); + service = new NotificationService({ + repo: t.repos.notifications, + bus, + clock: t.clock, + ids, + logger, + uow: t.uow, + outbox: { plan: (m, now) => outbox.plan(m, now), kick: () => undefined }, + }); + const listeners = new NotificationActionListeners({ + registry, + handler: (press) => actions.press(press), + logger, + }); + listeners.start(); + stops.push(() => listeners.stop()); + const produce = service.produce.bind(service); + return { outbox, produce, actions, actors, listeners, record }; +} + +const REQUEST = 'a-000000000001'; + +describe('act buttons end to end', () => { + it('telegram: press a callback button, the request resolves, the edit removes the buttons', async () => { + const w = await wire( + platformRecord('telegram', { + target: { chat_id: '-100123' }, + secretRefs: { token: 'BH_TG_TOKEN' }, + rules: { content: 'full', act_buttons: true, allow_list: ['42'] }, + }), + { BH_TG_TOKEN: FAKE_TG_TOKEN }, + ); + await w.produce(attentionCreated(REQUEST, 'takeover', { reason: 'captcha' })); + await w.outbox.tick(); + const send = fakes.of('telegram').find((r) => r.path === 'sendRichMessage'); + const keyboard = ( + (send?.json ?? {}) as { + reply_markup: { inline_keyboard: { text: string; callback_data?: string }[][] }; + } + ).reply_markup.inline_keyboard.flat(); + const resolve = keyboard.find((b) => b.text === 'Mark resolved')?.callback_data ?? ''; + expect(resolve).toMatch(/^bh1:[A-Za-z0-9_-]{11}$/); + // The token was stored (hashed) before the send. + expect(await t.repos.notificationActionTokens.get(sha256Hex(resolve.slice(4)))).not.toBeNull(); + fakes.updates.push({ + update_id: 900, + callback_query: { + id: 'cb900', + from: { id: 42, first_name: 'Amir' }, + message: { message_id: 101, chat: { id: -100123, type: 'supergroup' } }, + data: resolve, + }, + }); + await until(() => fakes.of('telegram').some((r) => r.path === 'answerCallbackQuery')); + expect(w.actors).toEqual(['telegram:42']); + expect(fakes.of('telegram').find((r) => r.path === 'answerCallbackQuery')?.json).toEqual({ + callback_query_id: 'cb900', + text: 'Marked resolved.', + }); + t.clock.advance(5_000); + await w.outbox.tick(); + const edit = fakes.of('telegram').find((r) => r.path === 'editMessageText'); + const body = edit?.json as { rich_message: { html: string }; reply_markup: unknown }; + expect(body.rich_message.html).toContain('Resolved on Telegram by 42'); + expect(body.reply_markup).toEqual({ inline_keyboard: [] }); + const audit = await t.repos.notificationActions.list({}); + expect(audit.map((a) => [a.actor, a.outcome, a.actionLabel])).toEqual([ + ['telegram:42', 'done', 'Mark resolved'], + ]); + // The same button again (Telegram re-sends, or a second tap): refused, the command is not re-run. + fakes.updates.push({ + update_id: 901, + callback_query: { + id: 'cb901', + from: { id: 42 }, + message: { message_id: 101, chat: { id: -100123 } }, + data: resolve, + }, + }); + await until( + () => fakes.of('telegram').filter((r) => r.path === 'answerCallbackQuery').length === 2, + ); + expect(w.actors).toHaveLength(1); + expect((await t.repos.notificationActions.list({}))[0]?.outcome).toBe('used'); + // The offset is stored after the update was handled (right after its answer). + let cursor: string | null = null; + await until(() => { + void t.repos.notificationCursors.get('telegram:1234').then((v) => { + cursor = v; + }); + return cursor === '902'; + }); + }); + + it('discord bot: press an interactive button over the gateway', async () => { + const w = await wire( + platformRecord('discord', { + mode: 'bot', + target: { channel_id: FAKE_DISCORD.channelId }, + secretRefs: { token: 'BH_DISCORD_BOT' }, + rules: { act_buttons: true, allow_list: [FAKE_DISCORD.userId] }, + }), + { BH_DISCORD_BOT: FAKE_DISCORD_BOT_TOKEN }, + ); + await until(() => w.listeners.status(w.record.channelId)?.state === 'connected'); + await w.produce(attentionCreated(REQUEST, 'takeover')); + await w.outbox.tick(); + const send = fakes + .of('discord-bot') + .find((r) => r.method === 'POST' && r.path.endsWith('/messages')); + const buttons = ( + (send?.json ?? {}) as { + components: { components: { label: string; custom_id?: string }[] }[]; + } + ).components.flatMap((r) => r.components); + const reject = buttons.find((b) => b.label === 'Reject')?.custom_id ?? ''; + expect(reject).toMatch(/^bh1:/); + fakes.discordPress(reject); + await until(() => fakes.of('discord-bot').some((r) => r.path.startsWith('/interactions/'))); + expect(w.actors).toEqual([`discord:${FAKE_DISCORD.userId}`]); + t.clock.advance(5_000); + await w.outbox.tick(); + const edit = fakes.of('discord-bot').find((r) => r.method === 'PATCH'); + expect(JSON.stringify(edit?.json)).toContain(`Resolved on Discord by ${FAKE_DISCORD.userId}`); + expect(((edit?.json ?? {}) as { components: unknown[] }).components).toEqual([]); + // A stranger's press is refused and audited with their id. + fakes.discordPress(buttons.find((b) => b.label === 'Mark resolved')?.custom_id ?? '', { + userId: '999999999999999999', + }); + await until( + () => fakes.of('discord-bot').filter((r) => r.path.startsWith('/interactions/')).length === 2, + ); + expect((await t.repos.notificationActions.list({}))[0]?.outcome).toBe('stale'); + }); + + it('ntfy: the phone posts the token to the reply topic', async () => { + const w = await wire( + platformRecord('ntfy', { + target: { server: fakes.ntfyServer, topic: 'bh-alerts', reply_topic: 'bh-replies' }, + rules: { act_buttons: true }, + }), + {}, + ); + await until(() => w.listeners.status(w.record.channelId)?.state === 'connected'); + await w.produce(attentionCreated(REQUEST, 'takeover')); + await w.outbox.tick(); + const send = fakes.of('ntfy').find((r) => r.method === 'POST'); + const actions = ( + (send?.json ?? {}) as { + actions: { action: string; label: string; body?: string; url: string }[]; + } + ).actions; + const resolve = actions.find((a) => a.label === 'Mark resolved'); + expect(resolve?.url).toBe(`${fakes.ntfyServer}/bh-replies`); + fakes.ntfyPost('bh-replies', resolve?.body ?? ''); + await until(() => w.actors.length === 1); + expect(w.actors).toEqual(['ntfy:topic-b']); + t.clock.advance(5_000); + await w.outbox.tick(); + const replaced = fakes.ntfyTopic('bh-alerts').at(-1); + expect(replaced?.['sequence_id']).toBe( + send && (send.json as { sequence_id: string }).sequence_id, + ); + expect(String(replaced?.['message'])).toContain('Resolved from ntfy'); + }); +}); diff --git a/packages/core/test/notifications/adapters.test.ts b/packages/core/test/notifications/adapters.test.ts index 8d35304..ab5e5c6 100644 --- a/packages/core/test/notifications/adapters.test.ts +++ b/packages/core/test/notifications/adapters.test.ts @@ -16,7 +16,13 @@ import { WEBHOOK_CAPABILITIES, } from '../../src/infra/notifications/index.ts'; import { ChannelSendError } from '../../src/ports/notification-channel.ts'; -import { FAKE_DISCORD_TOKEN, FAKE_TG_TOKEN, FakePlatforms } from '../helpers/fake-platforms.ts'; +import { + FAKE_DISCORD, + FAKE_DISCORD_BOT_TOKEN, + FAKE_DISCORD_TOKEN, + FAKE_TG_TOKEN, + FakePlatforms, +} from '../helpers/fake-platforms.ts'; import { delivery, LOCAL_LINKS, platformRecord, SAMPLE_IMAGES } from './helpers.ts'; let fakes: FakePlatforms; @@ -54,52 +60,66 @@ function telegram(overrides = {}) { } describe('telegram', () => { - it('sends HTML text with an inline keyboard and returns the ref', async () => { + const richOf = (req: { json: unknown; form: Record | null } | undefined) => { + const raw = + ((req?.json ?? {}) as { rich_message?: unknown } | null)?.rich_message ?? + req?.form?.['rich_message']; + return (typeof raw === 'string' ? JSON.parse(raw) : raw) as { + html: string; + media?: { id: string; media: { type: string; media: string } }[]; + skip_entity_detection?: boolean; + }; + }; + + it('sends a Rich Message with an inline keyboard and returns the ref', async () => { const channel = telegram(); const { ref } = await channel.send(delivery('attention', TELEGRAM_CAPABILITIES)); - expect(ref).toEqual({ chat_id: -100123, message_id: 101, photo: 0 }); + expect(ref).toEqual({ chat_id: -100123, message_id: 101, photo: 0, rich: 1 }); const [req] = fakes.of('telegram'); - expect(req?.path).toBe('sendMessage'); - expect(req?.json).toMatchObject({ - chat_id: '-100123', - parse_mode: 'HTML', - disable_notification: false, - }); - const body = req?.json as { - text: string; - reply_markup: { inline_keyboard: { url: string }[][] }; - }; - expect(body.text).toContain('Attention requested'); + expect(req?.path).toBe('sendRichMessage'); + expect(req?.json).toMatchObject({ chat_id: '-100123', disable_notification: false }); + const rich = richOf(req); + expect(rich.html.startsWith('

    ⚠️ Attention requested

    ')).toBe(true); + expect(rich.html).toContain(''); + expect(rich.skip_entity_detection).toBe(true); + expect(rich.media).toBeUndefined(); + const body = req?.json as { reply_markup: { inline_keyboard: { url: string }[][] } }; expect(body.reply_markup.inline_keyboard.flat().map((b) => b.url)).toContain( 'https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1', ); }); - it('sends a screenshot as a photo and edits its caption', async () => { + it('sends a screenshot as a media block and edits it again by its file id', async () => { const channel = telegram(); const { ref } = await channel.send( delivery('attention', TELEGRAM_CAPABILITIES, { image: 'masked' }), ); - expect(ref['photo']).toBe(1); - const [photo] = fakes.of('telegram'); - expect(photo?.path).toBe('sendPhoto'); - expect(photo?.files).toEqual([ - { field: 'photo', name: 'screenshot.jpg', type: 'image/jpeg', size: 8 }, + expect(ref).toMatchObject({ photo: 1, rich: 1, photo_file_id: 'rp-large' }); + const [send] = fakes.of('telegram'); + expect(send?.path).toBe('sendRichMessage'); + expect(send?.files).toEqual([ + { field: 'shot', name: 'screenshot.jpg', type: 'image/jpeg', size: 8 }, ]); - expect(photo?.form?.['caption']).toContain('Attention requested'); + const rich = richOf(send); + expect(rich.html).toContain(''); + expect(rich.media).toEqual([{ id: 'shot', media: { type: 'photo', media: 'attach://shot' } }]); await channel.edit?.( ref, delivery('attention-resolved', TELEGRAM_CAPABILITIES, { image: 'masked' }), ); const edit = fakes.of('telegram')[1]; - expect(edit?.path).toBe('editMessageCaption'); + expect(edit?.path).toBe('editMessageText'); + expect(edit?.files).toEqual([]); expect(edit?.json).toMatchObject({ chat_id: -100123, message_id: 101 }); - expect((edit?.json as { reply_markup: unknown } | undefined)?.reply_markup).toEqual({ + expect(richOf(edit).media).toEqual([ + { id: 'shot', media: { type: 'photo', media: 'rp-large' } }, + ]); + expect(((edit?.json ?? {}) as { reply_markup: unknown } | undefined)?.reply_markup).toEqual({ inline_keyboard: [], }); }); - it('falls back to a text message when the screenshot is gone', async () => { + it('sends the message without its screenshot when the image is gone', async () => { const channel = createTelegramChannel( platformRecord('telegram', { target: { chat_id: '1' } }), { @@ -111,10 +131,96 @@ describe('telegram', () => { const { ref } = await channel.send( delivery('attention', TELEGRAM_CAPABILITIES, { image: 'masked' }), ); - expect(fakes.of('telegram')[0]?.path).toBe('sendMessage'); + const [req] = fakes.of('telegram'); + expect(req?.path).toBe('sendRichMessage'); + expect(richOf(req).html).not.toContain('tg://photo'); expect(ref['photo']).toBe(0); }); + it('falls back to classic HTML when Telegram refuses the Rich Message, and stays classic after a 404', async () => { + const channel = telegram(); + fakes.script('telegram:sendRichMessage', { + status: 400, + body: { ok: false, error_code: 400, description: "Bad Request: can't parse rich message" }, + }); + const first = await channel.send(delivery('attention', TELEGRAM_CAPABILITIES)); + expect(first.ref).toMatchObject({ rich: 0, photo: 0 }); + expect(fakes.of('telegram').map((r) => r.path)).toEqual(['sendRichMessage', 'sendMessage']); + const classic = fakes.of('telegram')[1]?.json as { text: string; parse_mode: string }; + expect(classic.parse_mode).toBe('HTML'); + expect(classic.text).toContain('Attention requested'); + // A content refusal is per message: the next send tries a Rich Message again. + await channel.send(delivery('crash', TELEGRAM_CAPABILITIES)); + expect(fakes.of('telegram')[2]?.path).toBe('sendRichMessage'); + // A server without the method (404) keeps the channel classic. + fakes.script('telegram:sendRichMessage', { + status: 404, + body: { ok: false, error_code: 404, description: 'Not Found' }, + }); + await channel.send(delivery('crash', TELEGRAM_CAPABILITIES)); + await channel.send(delivery('test', TELEGRAM_CAPABILITIES)); + expect( + fakes + .of('telegram') + .map((r) => r.path) + .slice(3), + ).toEqual(['sendRichMessage', 'sendMessage', 'sendMessage']); + }); + + it('edits a message in the format it was sent in, and falls back when a rich edit is refused', async () => { + const channel = telegram(); + await channel.edit?.( + { chat_id: 1, message_id: 9, photo: 1 }, + delivery('attention-resolved', TELEGRAM_CAPABILITIES), + ); + expect(fakes.of('telegram')[0]?.path).toBe('editMessageCaption'); + fakes.script('telegram:editMessageText', { + status: 400, + body: { ok: false, error_code: 400, description: 'Bad Request: rich message is invalid' }, + }); + const { ref } = await required(channel.edit)( + { chat_id: 1, message_id: 10, photo: 1, rich: 1, photo_file_id: 'rp-large' }, + delivery('attention-resolved', TELEGRAM_CAPABILITIES), + ); + const [rich, classic] = fakes.of('telegram').slice(1); + expect(richOf(rich).html).toContain('Attention requested'); + expect(classic?.path).toBe('editMessageText'); + expect(classic?.json).toMatchObject({ parse_mode: 'HTML', message_id: 10 }); + expect(ref).toMatchObject({ rich: 0, photo: 0 }); + }); + + it('draws act buttons as styled callback buttons carrying the minted tokens', async () => { + const channel = telegram({ rules: { act_buttons: true } }); + expect(channel.capabilities.actButtons).toBe(true); + const d = delivery('attention', channel.capabilities); + await channel.send({ + ...d, + actTokens: new Map([ + ['resolve', 'bh1:AAAAAAAAAAA'], + ['reject', 'bh1:BBBBBBBBBBB'], + ]), + }); + const body = fakes.of('telegram')[0]?.json as { + reply_markup: { + inline_keyboard: { text: string; callback_data?: string; style?: string }[][]; + }; + }; + const buttons = body.reply_markup.inline_keyboard.flat(); + expect(buttons.find((b) => b.text === 'Mark resolved')).toEqual({ + text: 'Mark resolved', + callback_data: 'bh1:AAAAAAAAAAA', + }); + expect(buttons.find((b) => b.text === 'Reject')).toEqual({ + text: 'Reject', + callback_data: 'bh1:BBBBBBBBBBB', + style: 'danger', + }); + expect(buttons.find((b) => b.text === 'Take over')?.style).toBe('primary'); + // A delivery without the tokens is refused rather than sent with dead buttons. + const refused = await failure(channel.send(d)); + expect(refused.code).toBe('rejected'); + }); + it('edits text, treats "not modified" as done, and replies within a thread', async () => { const channel = telegram({ target: { chat_id: '-100123', thread_id: '7' } }); await channel.send( @@ -134,7 +240,7 @@ describe('telegram', () => { it('classifies vanished, too old, rate limited, auth and 5xx failures', async () => { const channel = telegram(); - const ref = { chat_id: 1, message_id: 9, photo: 0 }; + const ref = { chat_id: 1, message_id: 9, photo: 0, rich: 1 }; fakes.script('telegram:editMessageText', { status: 400, body: { ok: false, description: 'Bad Request: message to edit not found' }, @@ -147,7 +253,7 @@ describe('telegram', () => { body: { ok: false, description: "Bad Request: message can't be deleted for everyone" }, }); expect((await failure(required(channel.delete)(ref))).code).toBe('too_old'); - fakes.script('telegram:sendMessage', { + fakes.script('telegram:sendRichMessage', { status: 429, body: { ok: false, @@ -161,14 +267,14 @@ describe('telegram', () => { 7000, true, ]); - fakes.script('telegram:sendMessage', { + fakes.script('telegram:sendRichMessage', { status: 401, body: { ok: false, description: 'Unauthorized' }, }); const auth = await failure(channel.send(delivery('crash', TELEGRAM_CAPABILITIES))); expect([auth.code, auth.retryable]).toEqual(['auth', false]); expect(auth.message).not.toContain(FAKE_TG_TOKEN); - fakes.script('telegram:sendMessage', { + fakes.script('telegram:sendRichMessage', { status: 502, body: { ok: false, description: 'Bad Gateway' }, }); @@ -179,10 +285,11 @@ describe('telegram', () => { it('puts links in the text instead of buttons without a public address', async () => { await telegram().send(delivery('test', TELEGRAM_CAPABILITIES, { links: LOCAL_LINKS })); - const body = fakes.of('telegram')[0]?.json as { text: string; reply_markup?: unknown }; - expect(body.reply_markup).toBeUndefined(); - expect(body.text).toContain('Open on this computer'); - expect(body.text).toContain('http://127.0.0.1:9876/notifications/channels'); + const req = fakes.of('telegram')[0]; + expect(((req?.json ?? {}) as { reply_markup?: unknown }).reply_markup).toBeUndefined(); + const html = richOf(req).html; + expect(html).toContain('Open on this computer'); + expect(html).toContain('http://127.0.0.1:9876/notifications/channels'); }); }); @@ -228,7 +335,8 @@ describe('discord (webhook mode)', () => { const [send] = fakes.of('discord'); expect(send?.files.map((f) => f.field)).toEqual(['files[0]']); expect( - (send?.json as { embeds: { image: { url: string } }[] } | undefined)?.embeds[0]?.image.url, + ((send?.json ?? {}) as { embeds: { image: { url: string } }[] } | undefined)?.embeds[0]?.image + .url, ).toBe('attachment://screenshot.jpg'); expect(ref['attachment_id']).toBe('900101'); await channel.edit?.( @@ -237,7 +345,7 @@ describe('discord (webhook mode)', () => { ); const edit = fakes.of('discord')[1]; expect([edit?.method, edit?.path]).toEqual(['PATCH', 'messages/101']); - expect((edit?.json as { attachments: unknown } | undefined)?.attachments).toEqual([ + expect(((edit?.json ?? {}) as { attachments: unknown } | undefined)?.attachments).toEqual([ { id: '900101' }, ]); }); @@ -269,13 +377,112 @@ describe('discord (webhook mode)', () => { expect(fakes.of('discord').at(-1)?.method).toBe('DELETE'); }); - it('refuses bot mode until act buttons ship', () => { + it('refuses a bot-mode channel without its token or channel', () => { expect(() => createDiscordChannel(platformRecord('discord', { mode: 'bot' }), { - webhookUrl: fakes.discordWebhook, images: SAMPLE_IMAGES, }), - ).toThrow(/bot mode/); + ).toThrow(/bot token/); + expect(() => + createDiscordChannel(platformRecord('discord', { mode: 'bot' }), { + botToken: FAKE_DISCORD_BOT_TOKEN, + images: SAMPLE_IMAGES, + }), + ).toThrow(/names no Discord channel/); + }); +}); + +describe('discord (bot mode)', () => { + const bot = (rules = {}) => + createDiscordChannel( + platformRecord('discord', { + mode: 'bot', + target: { channel_id: FAKE_DISCORD.channelId, guild_id: FAKE_DISCORD.guildId }, + rules, + }), + { botToken: FAKE_DISCORD_BOT_TOKEN, images: SAMPLE_IMAGES, apiBase: fakes.discordApi }, + ); + + it('sends through the bot API with interactive act buttons, edits and deletes', async () => { + const channel = bot({ act_buttons: true }); + expect(channel.capabilities.actButtons).toBe(true); + const d = delivery('attention', channel.capabilities, { image: 'masked' }); + const { ref } = await channel.send({ + ...d, + actTokens: new Map([ + ['resolve', 'bh1:AAAAAAAAAAA'], + ['reject', 'bh1:BBBBBBBBBBB'], + ]), + }); + const [send] = fakes.of('discord-bot'); + expect([send?.method, send?.path]).toEqual([ + 'POST', + `/channels/${FAKE_DISCORD.channelId}/messages`, + ]); + expect(send?.headers['authorization']).toBe(`Bot ${FAKE_DISCORD_BOT_TOKEN}`); + expect(send?.files.map((f) => f.field)).toEqual(['files[0]']); + const buttons = ( + (send?.json ?? {}) as { + components: { components: { custom_id?: string; style: number; label: string }[] }[]; + } + ).components.flatMap((row) => row.components); + expect(buttons.filter((b) => b.custom_id !== undefined)).toEqual([ + { type: 2, style: 2, label: 'Mark resolved', custom_id: 'bh1:AAAAAAAAAAA' }, + { type: 2, style: 4, label: 'Reject', custom_id: 'bh1:BBBBBBBBBBB' }, + ] as never); + expect(ref['attachment_id']).toBeDefined(); + await channel.edit?.( + ref, + delivery('attention-resolved', channel.capabilities, { image: 'masked' }), + ); + const edit = fakes.of('discord-bot')[1]; + expect([edit?.method, edit?.path]).toEqual([ + 'PATCH', + `/channels/${FAKE_DISCORD.channelId}/messages/${ref['message_id']}`, + ]); + expect(((edit?.json ?? {}) as { components: unknown[] }).components).toEqual([]); + await channel.delete?.(ref); + const del = fakes.of('discord-bot')[2]; + expect([del?.method, del?.path]).toEqual([ + 'DELETE', + `/channels/${FAKE_DISCORD.channelId}/messages/${ref['message_id']}`, + ]); + }); + + it('names the field of an Invalid Form Body', async () => { + fakes.script('discord-bot:POST channels', { + status: 400, + body: { + message: 'Invalid Form Body', + code: 50035, + errors: { + components: { + '0': { + components: { + '1': { custom_id: { _errors: [{ code: 'X', message: 'Duplicate custom_id' }] } }, + }, + }, + }, + }, + }, + }); + const err = await failure(bot().send(delivery('crash', DISCORD_WEBHOOK_CAPABILITIES))); + expect(err.message).toBe( + 'Discord 400: Invalid Form Body (components.0.components.1.custom_id: Duplicate custom_id)', + ); + }); + + it('keeps act buttons as links while they are off, and never leaks the bot token', async () => { + const channel = bot(); + expect(channel.capabilities.actButtons).toBe(false); + expect(channel.presses).toBeUndefined(); + fakes.script('discord-bot:POST channels', { + status: 401, + body: { message: `401: Unauthorized ${FAKE_DISCORD_BOT_TOKEN}`, code: 0 }, + }); + const err = await failure(channel.send(delivery('attention', channel.capabilities))); + expect(err.code).toBe('auth'); + expect(err.message).not.toContain(FAKE_DISCORD_BOT_TOKEN); }); }); @@ -296,7 +503,7 @@ describe('telegram button URLs', () => { { mode: null, target: { chat_id: '1' }, op: 'send', ref: null, actToken: () => 'x' }, ); expect(request?.body['reply_markup']).toBeUndefined(); - expect(String(request?.body['text'])).toContain('🔗 Links'); + expect(JSON.stringify(request?.body['rich_message'])).toContain('🔗 Links'); }); }); @@ -322,7 +529,7 @@ describe('ntfy', () => { markdown: false, sequence_id: 'n-sample000001', }); - expect((send?.json as { actions: unknown[] } | undefined)?.actions).toHaveLength(2); + expect(((send?.json ?? {}) as { actions: unknown[] } | undefined)?.actions).toHaveLength(2); expect(ref['sequence_id']).toBe('n-sample000001'); await channel.edit?.(ref, delivery('attention-resolved', NTFY_CAPABILITIES)); expect(fakes.of('ntfy')[1]?.json).toMatchObject({ @@ -378,6 +585,55 @@ describe('ntfy', () => { const body = fakes.of('ntfy')[0]?.json as { actions: { label: string }[] }; expect(body.actions[0]?.label).toBe('Open on this computer'); }); + it('turns act buttons into http actions that post the token to the reply topic', async () => { + const channel = createNtfyChannel( + platformRecord('ntfy', { + target: { server: fakes.ntfyServer, topic: 'bh-alerts' }, + secretRefs: { reply_topic: 'BH_REPLY' }, + rules: { act_buttons: true }, + }), + { token: null, topic: null, replyTopic: 'bh-replies-x', images: SAMPLE_IMAGES }, + ); + expect(channel.capabilities.actButtons).toBe(true); + expect(channel.presses).toBeDefined(); + const d = delivery('vault-confirm', channel.capabilities); + await channel.send({ + ...d, + actTokens: new Map([ + ['approve', 'bh1:AAAAAAAAAAA'], + ['deny', 'bh1:BBBBBBBBBBB'], + ]), + }); + const body = fakes.of('ntfy')[0]?.json as { + actions: { action: string; label: string; url: string; body?: string; clear: boolean }[]; + click?: string; + }; + expect(body.actions).toEqual([ + { + action: 'http', + label: 'Approve', + url: `${fakes.ntfyServer}/bh-replies-x`, + method: 'POST', + body: 'bh1:AAAAAAAAAAA', + clear: true, + }, + { + action: 'http', + label: 'Deny', + url: `${fakes.ntfyServer}/bh-replies-x`, + method: 'POST', + body: 'bh1:BBBBBBBBBBB', + clear: true, + }, + { + action: 'view', + label: 'Review', + url: 'https://bh.example.net/vault?tab=confirm', + clear: false, + }, + ] as never); + expect(body.click).toBe('https://bh.example.net/vault?tab=confirm'); + }); }); describe('generic webhook', () => { diff --git a/packages/core/test/notifications/channel-service.test.ts b/packages/core/test/notifications/channel-service.test.ts index d819348..323a513 100644 --- a/packages/core/test/notifications/channel-service.test.ts +++ b/packages/core/test/notifications/channel-service.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; import type { DomainEvents } from '../../src/app/events/catalog.ts'; import { ChannelRegistry } from '../../src/app/notifications/channel-registry.ts'; -import { ChannelService } from '../../src/app/notifications/channel-service.ts'; +import { ChannelService, targetHint } from '../../src/app/notifications/channel-service.ts'; import { createPublicLinkBuilder } from '../../src/app/notifications/links.ts'; import { CHANNEL_RENDERERS, channelFactories } from '../../src/infra/notifications/index.ts'; import { AppError } from '../../src/kernel/errors/app-error.ts'; @@ -177,7 +177,28 @@ describe('ChannelService writes', () => { rules: {}, }), ), - ).toBe('CHANNEL_KIND_UNAVAILABLE'); + ).toBe('VALIDATION_FAILED'); + // Act buttons need a platform that receives presses; allow-lists take numeric ids. + expect( + await codeOf( + service.create({ + name: 'hooky', + kind: 'discord', + target: {}, + secret_refs: { webhook: 'BH_W' }, + rules: { act_buttons: true }, + }), + ), + ).toBe('VALIDATION_FAILED'); + expect( + await codeOf( + service.create({ + ...base, + secret_refs: { token: 'BH_TELEGRAM_TOKEN' }, + rules: { act_buttons: true, allow_list: ['me'] }, + }), + ), + ).toBe('VALIDATION_FAILED'); expect( await codeOf( service.create({ @@ -301,8 +322,37 @@ describe('ChannelService test send, preview and the delivery log', () => { content_type: 'image/jpeg', }); expect(preview.message.privacy.has_image).toBe(true); - const bot = service.preview({ kind: 'discord', mode: 'bot', sample: 'attention' }); + const bot = service.preview({ + kind: 'discord', + mode: 'bot', + target: { channel_id: '112233445566778899' }, + rules: { act_buttons: true }, + sample: 'attention', + }); expect(bot.capabilities.act_buttons).toBe(true); + expect(JSON.stringify(bot.requests)).toContain('bh1:preview-reject'); + expect(bot.notes.some((n) => n.includes('allow-list'))).toBe(true); + // A draft's ntfy reply topic from a variable enables the answer buttons; a pasted value is ignored. + const ntfy = service.preview({ + kind: 'ntfy', + target: { topic: 'bh-alerts' }, + secret_refs: { reply_topic: 'BH_REPLY', token: 'tk_pasted value' }, + rules: { act_buttons: true }, + sample: 'attention', + }); + expect(ntfy.capabilities.act_buttons).toBe(true); + expect(JSON.stringify(ntfy.requests)).toContain('{BH_REPLY}'); + expect(JSON.stringify(ntfy.requests)).not.toContain('tk_pasted'); + expect( + targetHint({ + kind: 'discord', + mode: 'bot', + target: { channel_id: '112233445566778899', channel_name: 'alerts', guild_name: 'Home' }, + secretRefs: { token: 'BH_BOT' }, + }), + ).toBe('bot · #alerts in Home'); + const off = service.preview({ kind: 'discord', mode: 'bot', sample: 'attention' }); + expect(off.capabilities.act_buttons).toBe(false); expect(fakes.requests).toHaveLength(0); }); diff --git a/packages/core/test/notifications/full-path.sqlite.test.ts b/packages/core/test/notifications/full-path.sqlite.test.ts index 50e5f1c..2b6440a 100644 --- a/packages/core/test/notifications/full-path.sqlite.test.ts +++ b/packages/core/test/notifications/full-path.sqlite.test.ts @@ -98,7 +98,7 @@ describe('full notification path through the real adapters', () => { expect(w.registered).toContain(FAKE_TG_TOKEN); const log = await lifecycle(w); expect(calls(fakes.of('telegram'))).toEqual([ - 'POST sendMessage', + 'POST sendRichMessage', 'POST editMessageText', 'POST deleteMessage', ]); @@ -107,9 +107,12 @@ describe('full notification path through the real adapters', () => { ['edit', '2', 'sent', ''], ['send', '1', 'sent', ''], ]); - const edit = fakes.of('telegram')[1]?.json as { text: string; message_id: number }; + const edit = fakes.of('telegram')[1]?.json as { + rich_message: { html: string }; + message_id: number; + }; expect(edit.message_id).toBe(101); - expect(edit.text).toContain('Resolved by local'); + expect(edit.rich_message.html).toContain('Resolved by local'); }); it('discord: send, edit, TTL delete', async () => { diff --git a/packages/core/test/notifications/press-listeners.test.ts b/packages/core/test/notifications/press-listeners.test.ts new file mode 100644 index 0000000..be61355 --- /dev/null +++ b/packages/core/test/notifications/press-listeners.test.ts @@ -0,0 +1,447 @@ +/** @module test/notifications/press-listeners.test — the press listeners against the platform fakes (spec 09, 03 §9.6, D-38, D-41, D-42): the Telegram poller (callback presses answered, offsets stored and resumed after a restart, a re-delivered update handled once, a 409 reported offline, the setup's /start wait sharing the poller), the Discord gateway subset (Identify/Ready, the 3-second answer and the deferred follow-up, Resume after a drop, a zombie connection, Invalid Session, the "This is me" claim) and the ntfy reply subscription (presses, resume from the stored id). */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { + type CursorStore, + createDiscordSetup, + createNtfyReplySource, + createTelegramSetup, + DiscordGatewayHub, + TelegramUpdatesHub, +} from '../../src/infra/notifications/index.ts'; +import type { + ListenerStatus, + PressAnswer, + PressEvent, +} from '../../src/ports/notification-channel.ts'; +import { + FAKE_DISCORD, + FAKE_DISCORD_BOT_TOKEN, + FAKE_TG_TOKEN, + FakePlatforms, +} from '../helpers/fake-platforms.ts'; + +let fakes: FakePlatforms; +beforeEach(() => { + fakes = new FakePlatforms().start(); +}); +afterEach(async () => { + await fakes.stop(); +}); + +async function until(check: () => boolean, ms = 3_000): Promise { + const deadline = Date.now() + ms; + while (!check()) { + if (Date.now() > deadline) throw new Error('timed out waiting'); + await Bun.sleep(5); + } +} + +function memoryCursors(): CursorStore & { readonly map: Map } { + const map = new Map(); + return { + map, + get: async (key) => map.get(key) ?? null, + set: async (key, value) => { + map.set(key, value); + }, + }; +} + +function recorder(answer: Partial = {}) { + const presses: PressEvent[] = []; + const statuses: ListenerStatus['state'][] = []; + return { + presses, + statuses, + handler: async (press: PressEvent): Promise => { + presses.push(press); + return { outcome: 'done', text: 'Marked resolved.', refused: false, ...answer }; + }, + onStatus: (s: ListenerStatus) => statuses.push(s.state), + }; +} + +function callback(id: number, data: string, chatId = -100123, fromId = 42) { + return { + update_id: id, + callback_query: { + id: `cb${id}`, + from: { id: fromId, first_name: 'Amir' }, + message: { message_id: 7, chat: { id: chatId, type: 'supergroup' } }, + data, + }, + }; +} + +describe('Telegram update poller', () => { + it('hands a press to the channel of its chat, answers it and stores the offset', async () => { + const cursors = memoryCursors(); + const hub = new TelegramUpdatesHub({ + apiBase: fakes.telegramBase, + cursors, + pollSeconds: 1, + lingerMs: 0, + }); + const r = recorder(); + fakes.updates.push(callback(50, 'bh1:AAAAAAAAAAA'), callback(51, 'not-ours')); + const stop = hub.pressSource(FAKE_TG_TOKEN, '-100123').listen(r.handler, r.onStatus); + await until( + () => fakes.of('telegram').filter((q) => q.path === 'answerCallbackQuery').length === 2, + ); + expect(r.presses).toEqual([ + { + token: 'AAAAAAAAAAA', + origin: '-100123', + actor: { platform: 'telegram', id: '42', name: 'Amir' }, + }, + ]); + const answers = fakes.of('telegram').filter((q) => q.path === 'answerCallbackQuery'); + expect(answers.map((a) => a.json)).toEqual([ + { callback_query_id: 'cb50', text: 'Marked resolved.' }, + { callback_query_id: 'cb51' }, + ]); + await until(() => cursors.map.get('telegram:1234') === '52'); + expect(r.statuses).toContain('connected'); + stop(); + hub.stop(); + }); + + it('resumes after a restart from the stored offset, so a press made while stopped is handled once', async () => { + const cursors = memoryCursors(); + cursors.map.set('telegram:1234', '60'); + fakes.updates.push(callback(59, 'bh1:OLDOLDOLDOL'), callback(60, 'bh1:NEWNEWNEWNE')); + const hub = new TelegramUpdatesHub({ + apiBase: fakes.telegramBase, + cursors, + pollSeconds: 1, + lingerMs: 0, + }); + const r = recorder({ + outcome: 'stale', + text: 'This request is no longer waiting.', + refused: true, + }); + const stop = hub.pressSource(FAKE_TG_TOKEN, '-100123').listen(r.handler, r.onStatus); + await until(() => r.presses.length === 1); + expect(r.presses[0]?.token).toBe('NEWNEWNEWNE'); + const first = fakes.of('telegram').find((q) => q.path === 'getUpdates'); + expect(first?.json).toMatchObject({ offset: 60 }); + // Telegram re-delivers an update it did not see acknowledged: handled once. + fakes.updates.push(callback(60, 'bh1:NEWNEWNEWNE')); + await Bun.sleep(100); + expect(r.presses).toHaveLength(1); + // A refusal the presser must read is shown as an alert only for the allow-list. + const answer = fakes.of('telegram').find((q) => q.path === 'answerCallbackQuery'); + expect(answer?.json).toEqual({ + callback_query_id: 'cb60', + text: 'This request is no longer waiting.', + }); + stop(); + hub.stop(); + }); + + it('shows an allow-list refusal as an alert', async () => { + const hub = new TelegramUpdatesHub({ + apiBase: fakes.telegramBase, + pollSeconds: 1, + lingerMs: 0, + }); + const r = recorder({ + outcome: 'not_allowed', + text: 'Not allowed: your Telegram id 7…', + refused: true, + }); + fakes.updates.push(callback(70, 'bh1:AAAAAAAAAAA', -100123, 7)); + const stop = hub.pressSource(FAKE_TG_TOKEN, '-100123').listen(r.handler, r.onStatus); + await until(() => fakes.of('telegram').some((q) => q.path === 'answerCallbackQuery')); + expect(fakes.of('telegram').find((q) => q.path === 'answerCallbackQuery')?.json).toMatchObject({ + show_alert: true, + }); + stop(); + hub.stop(); + }); + + it('reports another poller (409) as offline, and a failure as reconnecting', async () => { + fakes.script('telegram:getUpdates', { + status: 409, + body: { + ok: false, + error_code: 409, + description: 'Conflict: terminated by other getUpdates request', + }, + }); + const hub = new TelegramUpdatesHub({ + apiBase: fakes.telegramBase, + pollSeconds: 1, + lingerMs: 0, + offlineRetryMs: { auth: 50, conflict: 50 }, + backoffMs: { min: 10, max: 20 }, + }); + const r = recorder(); + const source = hub.pressSource(FAKE_TG_TOKEN, '-100123'); + const stop = source.listen(r.handler, r.onStatus); + await until(() => r.statuses.includes('offline')); + expect(source.status().detail).toContain('Another program is polling this bot'); + await until(() => r.statuses.at(-1) === 'connected'); + fakes.script('telegram:getUpdates', { + status: 502, + body: { ok: false, description: 'Bad Gateway' }, + }); + await until(() => r.statuses.includes('reconnecting')); + stop(); + hub.stop(); + }); + + it('serves the setup /start wait from the same poller as the act buttons', async () => { + const hub = new TelegramUpdatesHub({ + apiBase: fakes.telegramBase, + pollSeconds: 1, + lingerMs: 0, + }); + const r = recorder(); + const stop = hub.pressSource(FAKE_TG_TOKEN, '-100123').listen(r.handler, r.onStatus); + const setup = createTelegramSetup({ apiBase: fakes.telegramBase, updates: hub }); + const wait = setup.waitForStart(FAKE_TG_TOKEN, 'c0de', { + signal: new AbortController().signal, + deadline: Date.now() + 3_000, + }); + fakes.updates.push( + { + update_id: 80, + message: { + message_id: 1, + text: '/start c0de', + chat: { id: 5, type: 'private', first_name: 'Amir' }, + from: { id: 5, first_name: 'Amir' }, + }, + }, + callback(81, 'bh1:AAAAAAAAAAA'), + ); + expect((await wait)?.user).toEqual({ id: '5', name: 'Amir' }); + await until(() => r.presses.length === 1); + // One poller: no two concurrent getUpdates (Telegram would answer 409). + const polls = fakes.of('telegram').filter((q) => q.path === 'getUpdates'); + expect(polls.length).toBeGreaterThan(0); + stop(); + hub.stop(); + }); +}); + +describe('Discord gateway', () => { + function hub(options: { answerWithinMs?: number } = {}) { + return new DiscordGatewayHub({ + apiBase: fakes.discordApi, + random: () => 0.5, + lingerMs: 0, + backoffMs: { min: 10, max: 50 }, + ...options, + }); + } + + it('identifies with intents 0, becomes connected and answers a quick press within the deadline', async () => { + const gateway = hub(); + const r = recorder(); + const stop = gateway + .pressSource(FAKE_DISCORD_BOT_TOKEN, FAKE_DISCORD.channelId) + .listen(r.handler, r.onStatus); + await until(() => r.statuses.includes('connected')); + const identify = fakes.gatewayFrames.find((f) => f.op === 2); + expect(identify?.d).toMatchObject({ token: FAKE_DISCORD_BOT_TOKEN, intents: 0 }); + const id = fakes.discordPress('bh1:AAAAAAAAAAA'); + await until(() => + fakes.of('discord-bot').some((q) => q.path.startsWith(`/interactions/${id}/`)), + ); + expect(r.presses[0]).toEqual({ + token: 'AAAAAAAAAAA', + origin: FAKE_DISCORD.channelId, + actor: { platform: 'discord', id: FAKE_DISCORD.userId, name: 'Op Erator' }, + }); + const callback = fakes.of('discord-bot').find((q) => q.path.startsWith('/interactions/')); + expect(callback?.path).toBe(`/interactions/${id}/itoken${id}/callback`); + expect(callback?.headers['authorization']).toBeUndefined(); + expect(callback?.json).toEqual({ type: 4, data: { content: 'Marked resolved.', flags: 64 } }); + stop(); + gateway.stop(); + }); + + it('defers a slow press and edits the answer when the command finishes', async () => { + const gateway = hub({ answerWithinMs: 50 }); + let release: (() => void) | undefined; + const slow = new Promise((resolve) => { + release = resolve; + }); + const stop = gateway.pressSource(FAKE_DISCORD_BOT_TOKEN, FAKE_DISCORD.channelId).listen( + async () => { + await slow; + return { outcome: 'done', text: 'Approved.', refused: false }; + }, + () => undefined, + ); + await until( + () => fakes.gatewayClientCount === 1 && fakes.gatewayFrames.some((f) => f.op === 2), + ); + await Bun.sleep(20); + fakes.discordPress('bh1:AAAAAAAAAAA'); + await until(() => fakes.of('discord-bot').some((q) => q.path.startsWith('/interactions/'))); + expect(fakes.of('discord-bot').find((q) => q.path.startsWith('/interactions/'))?.json).toEqual({ + type: 5, + data: { flags: 64 }, + }); + release?.(); + await until(() => fakes.of('discord-bot').some((q) => q.path.startsWith('/webhooks/'))); + const followUp = fakes.of('discord-bot').find((q) => q.path.startsWith('/webhooks/')); + expect([followUp?.method, followUp?.path, followUp?.json]).toEqual([ + 'PATCH', + `/webhooks/${FAKE_DISCORD.applicationId}/${followUp?.path.split('/')[3]}/messages/@original`, + { content: 'Approved.' }, + ]); + stop(); + gateway.stop(); + }); + + it('resumes after a dropped connection, re-identifies after an invalid session, and detects a zombie', async () => { + fakes.gatewayHeartbeatMs = 60; + const gateway = hub(); + const r = recorder(); + const stop = gateway + .pressSource(FAKE_DISCORD_BOT_TOKEN, FAKE_DISCORD.channelId) + .listen(r.handler, r.onStatus); + await until(() => r.statuses.includes('connected')); + fakes.gatewayDrop(1006); + await until(() => fakes.gatewayFrames.some((f) => f.op === 6)); + const resume = fakes.gatewayFrames.find((f) => f.op === 6); + expect(resume?.d).toMatchObject({ token: FAKE_DISCORD_BOT_TOKEN, session_id: 'session1' }); + await until(() => r.statuses.filter((s) => s === 'connected').length >= 2); + expect(r.statuses).toContain('reconnecting'); + // Invalid session (not resumable): a fresh Identify. + const identifies = () => fakes.gatewayFrames.filter((f) => f.op === 2).length; + fakes.gatewaySend({ op: 9, d: false }); + await until(() => identifies() === 2); + // A zombie: heartbeats stop being acknowledged. + fakes.gatewayAcks = false; + const connections = fakes.gatewayConnections; + await until(() => fakes.gatewayConnections > connections, 2_000); + fakes.gatewayAcks = true; + // Heartbeats carry the last sequence number. + expect(fakes.gatewayFrames.some((f) => f.op === 1)).toBe(true); + stop(); + gateway.stop(); + }); + + it('answers a refusal ephemerally, so only the presser sees it', async () => { + const gateway = hub(); + const r = recorder({ + outcome: 'not_allowed', + text: 'You are not allowed to answer here yet.', + refused: true, + }); + const stop = gateway + .pressSource(FAKE_DISCORD_BOT_TOKEN, FAKE_DISCORD.channelId) + .listen(r.handler, r.onStatus); + await until(() => r.statuses.includes('connected')); + fakes.discordPress('bh1:AAAAAAAAAAA', { userId: '500000000000000005' }); + await until(() => fakes.of('discord-bot').some((q) => q.path.startsWith('/interactions/'))); + expect(fakes.of('discord-bot').find((q) => q.path.startsWith('/interactions/'))?.json).toEqual({ + type: 4, + data: { content: 'You are not allowed to answer here yet.', flags: 64 }, + }); + expect(r.presses[0]?.actor.id).toBe('500000000000000005'); + stop(); + gateway.stop(); + }); + + it('reports a refused token as offline without retrying at once', async () => { + fakes.script('discord-bot:GET gateway', { + status: 401, + body: { message: '401: Unauthorized' }, + }); + const gateway = hub(); + const r = recorder(); + const source = gateway.pressSource(FAKE_DISCORD_BOT_TOKEN, FAKE_DISCORD.channelId); + const stop = source.listen(r.handler, r.onStatus); + await until(() => r.statuses.includes('offline')); + expect(source.status().detail).toBe('Discord refused the bot token.'); + stop(); + gateway.stop(); + }); + + it('links an account with the "This is me" button, and lists servers and channels', async () => { + const gateway = hub(); + const setup = createDiscordSetup({ apiBase: fakes.discordApi, gateway }); + const bot = await setup.bot(FAKE_DISCORD_BOT_TOKEN); + expect(bot).toEqual({ + applicationId: FAKE_DISCORD.applicationId, + botId: FAKE_DISCORD.botId, + username: 'bh_bot', + guilds: [{ id: FAKE_DISCORD.guildId, name: 'Home' }], + }); + expect(await setup.channels(FAKE_DISCORD_BOT_TOKEN, FAKE_DISCORD.guildId)).toEqual([ + { id: '300000000000000006', name: 'news', type: 'announcement', category: null }, + { id: FAKE_DISCORD.channelId, name: 'browserhive', type: 'text', category: 'Alerts' }, + ]); + const claim = setup.claim(FAKE_DISCORD_BOT_TOKEN, FAKE_DISCORD.channelId, { + signal: new AbortController().signal, + deadline: Date.now() + 3_000, + }); + await until(() => + fakes.of('discord-bot').some((q) => q.method === 'POST' && q.path.endsWith('/messages')), + ); + const posted = fakes + .of('discord-bot') + .find((q) => q.method === 'POST' && q.path.endsWith('/messages')); + const customId = ( + (posted?.json ?? {}) as { components: { components: { custom_id: string }[] }[] } + ).components[0]?.components[0]?.custom_id; + expect(customId).toMatch(/^bh1c:/); + await until( + () => fakes.gatewayClientCount === 1 && fakes.gatewayFrames.some((f) => f.op === 2), + ); + await Bun.sleep(20); + fakes.discordPress(customId ?? ''); + expect(await claim).toEqual({ id: FAKE_DISCORD.userId, name: 'Op Erator' }); + await until(() => fakes.of('discord-bot').some((q) => q.method === 'DELETE')); + gateway.stop(); + }); +}); + +describe('ntfy reply topic', () => { + it('hands each bh1 message to the handler and resumes from the stored id after a drop', async () => { + const cursors = memoryCursors(); + const source = createNtfyReplySource({ + server: fakes.ntfyServer, + topic: 'bh-replies-x', + token: 'tk_x', + cursorKey: 'ntfy:nc-1', + cursors, + backoffMs: { min: 10, max: 20 }, + }); + const r = recorder(); + const stop = source.listen(r.handler, r.onStatus); + await until(() => r.statuses.includes('connected')); + const phone = fakes.ntfyPost('bh-replies-x', 'bh1:AAAAAAAAAAA'); + fakes.ntfyPost('bh-replies-x', 'hello, not a token'); + await until(() => cursors.map.get('ntfy:nc-1') !== undefined && r.presses.length === 1); + expect(r.presses[0]).toEqual({ + token: 'AAAAAAAAAAA', + origin: null, + actor: { platform: 'ntfy', id: null, name: null }, + }); + const subscribe = fakes.of('ntfy').find((q) => q.method === 'GET'); + expect(subscribe?.headers['authorization']).toBe('Bearer tk_x'); + await until(() => cursors.map.get('ntfy:nc-1') !== phone['id']); + // A press while the connection is down arrives through `since=` on the next connection. + fakes.ntfyDrop(); + stop(); + fakes.ntfyPost('bh-replies-x', 'bh1:BBBBBBBBBBB'); + const again = source.listen(r.handler, r.onStatus); + await until(() => r.presses.length === 2); + expect(r.presses[1]?.token).toBe('BBBBBBBBBBB'); + const resumed = fakes + .of('ntfy') + .filter((q) => q.method === 'GET') + .at(-1); + expect(resumed?.query['since']).toMatch(/^m\d+$/); + again(); + }); +}); diff --git a/packages/core/test/notifications/render.golden.test.ts b/packages/core/test/notifications/render.golden.test.ts index a61ea75..e75eb86 100644 --- a/packages/core/test/notifications/render.golden.test.ts +++ b/packages/core/test/notifications/render.golden.test.ts @@ -5,8 +5,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { NotificationContentLevel } from '@browserhive/contracts/enums'; import { PREVIEW_SAMPLES, type PreviewSample } from '@browserhive/contracts/notifications'; -import { CHANNEL_RENDERERS } from '../../src/infra/notifications/index.ts'; +import { CHANNEL_RENDERERS, telegramClassicRenderer } from '../../src/infra/notifications/index.ts'; import type { + ChannelRenderer, LinkBuilder, PlatformMessageRef, RenderContext, @@ -18,13 +19,22 @@ const UPDATE = process.env['UPDATE_GOLDENS'] === '1'; const TARGETS: Readonly>>> = { telegram: { chat_id: '-1001234567890' }, + 'telegram-classic': { chat_id: '-1001234567890' }, discord: {}, ntfy: { server: 'https://ntfy.example.net', topic: 'bh-alerts' }, webhook: { url: 'https://hooks.example.net/bh' }, }; +/** Targets of the variants with act buttons on (a Discord bot channel, an ntfy reply topic). */ +const ACT_TARGETS: Readonly>>> = { + ...TARGETS, + discord: { channel_id: '112233445566778899', guild_id: '998877665544332211' }, + ntfy: { server: 'https://ntfy.example.net', topic: 'bh-alerts', reply_topic: 'bh-replies' }, +}; + const EDIT_REFS: Readonly> = { - telegram: { chat_id: -1001234567890, message_id: 101, photo: 0 }, + telegram: { chat_id: -1001234567890, message_id: 101, photo: 0, rich: 1 }, + 'telegram-classic': { chat_id: -1001234567890, message_id: 101, photo: 0 }, discord: { message_id: '1101', channel_id: '42' }, ntfy: { id: 'm1', sequence_id: 'n-sample000001' }, webhook: { notification_id: 'n-sample000001', revision: 1 }, @@ -32,7 +42,14 @@ const EDIT_REFS: Readonly> = { const IMAGE_EDIT_REFS: Readonly> = { ...EDIT_REFS, - telegram: { chat_id: -1001234567890, message_id: 101, photo: 1 }, + telegram: { + chat_id: -1001234567890, + message_id: 101, + photo: 1, + rich: 1, + photo_file_id: 'photo-file-1', + }, + 'telegram-classic': { chat_id: -1001234567890, message_id: 101, photo: 1 }, discord: { message_id: '1101', channel_id: '42', @@ -49,6 +66,8 @@ interface Variant { readonly level?: NotificationContentLevel; readonly mode?: string; readonly edit?: boolean; + /** Act buttons on (D-41). */ + readonly act?: boolean; } function variants(kind: string): Variant[] { @@ -66,16 +85,48 @@ function variants(kind: string): Variant[] { edit: true, }, ); - if (kind === 'discord') out.push({ name: 'attention-bot', sample: 'attention', mode: 'bot' }); + out.push( + { name: 'attention-act', sample: 'attention', act: true }, + { name: 'vault-confirm-act', sample: 'vault-confirm', act: true }, + { name: 'attention-image-act', sample: 'attention', image: 'masked', act: true }, + ); + if (kind === 'discord') { + out.push( + { name: 'attention-bot', sample: 'attention', mode: 'bot', act: true }, + { name: 'attention-bot-no-act', sample: 'attention', mode: 'bot' }, + { + name: 'attention-resolved-bot-edit', + sample: 'attention-resolved', + mode: 'bot', + act: true, + edit: true, + }, + ); + } + if (kind === 'telegram') { + out.push({ name: 'attention-classic-ref-edit', sample: 'attention-resolved', edit: true }); + } return out; } +/** Every renderer, plus the classic Telegram fallback (D-40) under its own golden folder. */ +const RENDERERS: [string, ChannelRenderer][] = [ + ...CHANNEL_RENDERERS, + ['telegram-classic', telegramClassicRenderer], +]; + describe('renderer goldens', () => { - for (const [kind, renderer] of CHANNEL_RENDERERS) { + for (const [kind, renderer] of RENDERERS) { for (const v of variants(kind)) { it(`${kind} ${v.name}`, () => { const mode = v.mode ?? (kind === 'discord' ? 'webhook' : null); - const capabilities = renderer.capabilities(mode); + const target = (v.act === true ? ACT_TARGETS : TARGETS)[kind] ?? {}; + const capabilities = renderer.capabilities({ + mode, + target, + secretRefs: {}, + rules: v.act === true ? { act_buttons: true } : {}, + }); const d = delivery(v.sample, capabilities, { ...(v.image !== undefined && { image: v.image }), links: v.links ?? PUBLIC_LINKS, @@ -83,13 +134,15 @@ describe('renderer goldens', () => { }); const context: RenderContext = { mode, - target: TARGETS[kind] ?? {}, + target, op: v.edit === true ? 'edit' : 'send', ref: v.edit === true - ? ((v.image === undefined ? EDIT_REFS : IMAGE_EDIT_REFS)[kind] ?? null) + ? v.name === 'attention-classic-ref-edit' + ? (EDIT_REFS['telegram-classic'] ?? null) + : ((v.image === undefined ? EDIT_REFS : IMAGE_EDIT_REFS)[kind] ?? null) : null, - actToken: () => 'bh1:preview', + actToken: (id) => `bh1:preview-${id}`, }; const body = { kind, variant: v.name, mode, requests: renderer.render(d, context) }; const dir = join(GOLDEN_DIR, kind); diff --git a/packages/core/test/notifications/render.property.test.ts b/packages/core/test/notifications/render.property.test.ts index 2267ab7..ec061da 100644 --- a/packages/core/test/notifications/render.property.test.ts +++ b/packages/core/test/notifications/render.property.test.ts @@ -15,7 +15,9 @@ import { discordRenderer, ntfyRenderer, TELEGRAM_CAPTION_MAX, + TELEGRAM_RICH_MAX, TELEGRAM_TEXT_MAX, + telegramClassicRenderer, telegramRenderer, } from '../../src/infra/notifications/index.ts'; import type { ChannelRenderer, RenderContext } from '../../src/ports/notification-channel.ts'; @@ -102,10 +104,16 @@ function render( next: () => number, mode: string | null, ) { - const capabilities = renderer.capabilities(mode); + const target = { chat_id: '1', topic: 't', reply_topic: 'r', channel_id: '112233445566778899' }; + const capabilities = renderer.capabilities({ + mode, + target, + secretRefs: {}, + rules: { act_buttons: true }, + }); const context: RenderContext = { mode, - target: { chat_id: '1', topic: 't' }, + target, op: 'send', ref: null, actToken: () => 'bh1:x', @@ -121,6 +129,8 @@ function render( } const TG_TAG = /<\/?(b|i|code|pre|blockquote|a|tg-time)(\s[^<>]*)?>/g; +const RICH_TAG = + /<\/?(h3|h4|p|br|img|b|i|code|pre|blockquote|a|tg-time|table|tr|td|th|ul|ol|li|footer|hr)(\s[^<>]*)?\/?>/g; const TG_ENTITY = /&(lt|gt|amp|quot);/g; /** Visible text of Telegram HTML, or an error sentence when it is not well formed. */ @@ -147,12 +157,12 @@ function telegramVisible(html: string): { visible: string } | { error: string } } describe('renderer properties', () => { - it('Telegram HTML is well formed and within the text and caption limits', () => { + it('classic Telegram HTML (the fallback) is well formed and within the text and caption limits', () => { const next = rng(7); const problems: string[] = []; let nearLimit = 0; for (let i = 0; i < 500; i++) { - const [request] = render(telegramRenderer, randomMessage(next), next, null); + const [request] = render(telegramClassicRenderer, randomMessage(next), next, null); const body = request?.body as { text?: string; caption?: string }; const html = body.caption ?? body.text ?? ''; const limit = body.caption !== undefined ? TELEGRAM_CAPTION_MAX : TELEGRAM_TEXT_MAX; @@ -167,6 +177,37 @@ describe('renderer properties', () => { expect(nearLimit).toBeGreaterThan(20); }); + it('Rich Message HTML is well formed, escaped and within the rich limit; buttons fit 64 bytes', () => { + const next = rng(8); + const problems: string[] = []; + for (let i = 0; i < 500; i++) { + const [request] = render(telegramRenderer, randomMessage(next), next, null); + const rich = request?.body['rich_message'] as { html: string } | undefined; + const html = rich?.html ?? ''; + if (html.length > TELEGRAM_RICH_MAX) problems.push(`case ${i}: ${html.length} too long`); + const stack: string[] = []; + for (const match of html.matchAll(RICH_TAG)) { + const tag = match[1] ?? ''; + if (match[0].endsWith('/>') || tag === 'br') continue; + if (match[0].startsWith('`); + } else stack.push(tag); + } + if (stack.length > 0) problems.push(`case ${i}: unclosed ${stack.join(',')}`); + const stripped = html.replace(RICH_TAG, ''); + if (/[<>]/.test(stripped)) problems.push(`case ${i}: raw < or > outside a tag`); + if (/&/.test(stripped.replace(TG_ENTITY, ''))) problems.push(`case ${i}: raw &`); + const markup = request?.body['reply_markup'] as + | { inline_keyboard: { callback_data?: string }[][] } + | undefined; + for (const b of markup?.inline_keyboard.flat() ?? []) { + if (b.callback_data !== undefined && new TextEncoder().encode(b.callback_data).length > 64) + problems.push(`case ${i}: callback_data over 64 bytes`); + } + } + expect(problems).toEqual([]); + }); + it('Discord embeds stay within Discord limits and never ping', () => { const next = rng(11); const problems: string[] = []; diff --git a/packages/core/test/notifications/renderer-redaction.property.test.ts b/packages/core/test/notifications/renderer-redaction.property.test.ts index f03cb1a..01b746c 100644 --- a/packages/core/test/notifications/renderer-redaction.property.test.ts +++ b/packages/core/test/notifications/renderer-redaction.property.test.ts @@ -112,7 +112,12 @@ describe('renderer redaction invariant', () => { if (message === null) continue; for (const [kind, renderer] of CHANNEL_RENDERERS) { for (const mode of kind === 'discord' ? ['webhook', 'bot'] : [null]) { - const capabilities = renderer.capabilities(mode); + const capabilities = renderer.capabilities({ + mode, + target: { reply_topic: 'r', channel_id: '112233445566778899' }, + secretRefs: {}, + rules: { act_buttons: true }, + }); for (const level of LEVELS) { for (const links of [PUBLIC_LINKS, LOCAL_LINKS]) { const requests = renderer.render( diff --git a/packages/core/test/notifications/setup-store-probe.test.ts b/packages/core/test/notifications/setup-store-probe.test.ts index 77a6cc2..ef8dc66 100644 --- a/packages/core/test/notifications/setup-store-probe.test.ts +++ b/packages/core/test/notifications/setup-store-probe.test.ts @@ -65,8 +65,18 @@ describe('telegram setup', () => { chat: { id: '-1009', title: 'Ops', type: 'supergroup', threadId: '3' }, user: { id: '77', name: 'Amir G' }, }); + // The shared poller acknowledges what it read with the next poll's offset. + const deadline = Date.now() + 2_000; + const acknowledged = () => + fakes + .of('telegram') + .some((r) => r.path === 'getUpdates' && (r.json as { offset?: number }).offset === 12); + while (!acknowledged() && Date.now() < deadline) await Bun.sleep(10); + expect(acknowledged()).toBe(true); const polls = fakes.of('telegram').filter((r) => r.path === 'getUpdates'); - expect(polls.at(-1)?.json).toMatchObject({ offset: 12 }); + expect(polls[0]?.json).toMatchObject({ + allowed_updates: ['message', 'callback_query', 'my_chat_member'], + }); }); it('gives up at the deadline and on abort', async () => { diff --git a/packages/core/test/persistence/conformance-actions.test.ts b/packages/core/test/persistence/conformance-actions.test.ts new file mode 100644 index 0000000..c6b5c63 --- /dev/null +++ b/packages/core/test/persistence/conformance-actions.test.ts @@ -0,0 +1,183 @@ +/** @module test/persistence/conformance-actions.test — one suite over the SQLite act-button repositories and their in-memory doubles (tokens: insert, get, single claim, prune, channel cascade; the press audit: insert, filters, keyset, prune; the listener cursors), so the doubles the app tests use behave like the real thing (spec 09, D-41). */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import type { + NewNotificationAction, + NotificationActionTokenRecord, + NotificationRecord, +} from '../../src/ports/persistence/records.ts'; +import type { Repositories } from '../../src/ports/persistence/unit-of-work.ts'; +import { channelRecord } from '../helpers/fake-channel.ts'; +import { InMemoryRepositories } from '../helpers/in-memory-repos.ts'; +import { openMemory, type TestDb } from './setup.ts'; + +type Repos = Pick< + Repositories, + | 'notifications' + | 'notificationChannels' + | 'notificationActionTokens' + | 'notificationActions' + | 'notificationCursors' +>; + +function notification(): NotificationRecord { + return { + notificationId: 'n-000000000001', + principalId: null, + type: 'attention', + title: 'Attention requested', + body: null, + sessionId: null, + target: null, + sourceEventId: 'a-000000000001', + createdAt: 10, + updatedAt: 10, + count: 1, + groupKey: null, + readAt: null, + dismissedAt: null, + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + revision: 1, + thread: 'attention:a-000000000001', + messageJson: '{"schema":1}', + }; +} + +function token( + overrides: Partial = {}, +): NotificationActionTokenRecord { + return { + tokenHash: 'a'.repeat(64), + channelId: 'nc-000000000001', + notificationId: 'n-000000000001', + actionId: 'resolve', + op: 'attention.resolve', + args: { request_id: 'a-000000000001', decision: 'resolve', urgent: true, n: 2 }, + createdAt: 100, + expiresAt: 1_000, + usedAt: null, + ...overrides, + }; +} + +function press(overrides: Partial = {}): NewNotificationAction { + return { + at: 200, + channelId: 'nc-000000000001', + channelName: 'phone', + channelKind: 'telegram', + notificationId: 'n-000000000001', + actionId: 'resolve', + actionLabel: 'Mark resolved', + op: 'attention.resolve', + args: { request_id: 'a-000000000001', decision: 'resolve' }, + actor: 'telegram:42', + actorName: 'Someone', + outcome: 'done', + detail: 'Marked resolved.', + ...overrides, + }; +} + +const adapters: ReadonlyArray< + readonly [string, () => Promise<{ repos: Repos; close(): Promise }>] +> = [ + [ + 'sqlite', + async () => { + const t: TestDb = await openMemory(); + return { repos: t.repos, close: () => t.close() }; + }, + ], + ['in-memory', async () => ({ repos: new InMemoryRepositories(), close: async () => undefined })], +]; + +for (const [name, open] of adapters) { + describe(`act-button repositories (${name})`, () => { + let r: Repos; + let close: () => Promise; + + beforeEach(async () => { + const opened = await open(); + r = opened.repos; + close = opened.close; + await r.notifications.insert(notification()); + await r.notificationChannels.upsert( + channelRecord({ channelId: 'nc-000000000001', name: 'phone', kind: 'telegram' }), + ); + }); + afterEach(async () => { + await close(); + }); + + it('stores a token by its hash and returns it with its args', async () => { + await r.notificationActionTokens.insert([token(), token({ tokenHash: 'b'.repeat(64) })]); + expect(await r.notificationActionTokens.get('a'.repeat(64))).toEqual(token()); + expect(await r.notificationActionTokens.get('c'.repeat(64))).toBeNull(); + }); + + it('claims a token once', async () => { + await r.notificationActionTokens.insert([token()]); + expect(await r.notificationActionTokens.claim('a'.repeat(64), 300)).toBe(true); + expect(await r.notificationActionTokens.claim('a'.repeat(64), 301)).toBe(false); + expect((await r.notificationActionTokens.get('a'.repeat(64)))?.usedAt).toBe(300); + expect(await r.notificationActionTokens.claim('c'.repeat(64), 302)).toBe(false); + }); + + it('prunes expired tokens and loses them with their channel', async () => { + await r.notificationActionTokens.insert([ + token(), + token({ tokenHash: 'b'.repeat(64), expiresAt: 5_000 }), + ]); + expect(await r.notificationActionTokens.prune(2_000)).toBe(1); + expect(await r.notificationActionTokens.get('a'.repeat(64))).toBeNull(); + await r.notificationChannels.remove('nc-000000000001'); + expect(await r.notificationActionTokens.get('b'.repeat(64))).toBeNull(); + }); + + it('audits presses newest first with filters and a keyset', async () => { + const first = await r.notificationActions.insert(press()); + const second = await r.notificationActions.insert( + press({ at: 210, outcome: 'not_allowed', actor: 'telegram:7', actorName: null }), + ); + const third = await r.notificationActions.insert( + press({ at: 220, channelId: 'nc-000000000002', channelName: 'team', notificationId: null }), + ); + expect(second.seq).toBeGreaterThan(first.seq); + expect((await r.notificationActions.list({})).map((a) => a.seq)).toEqual([ + third.seq, + second.seq, + first.seq, + ]); + expect( + (await r.notificationActions.list({ channelId: 'nc-000000000001' })).map((a) => a.seq), + ).toEqual([second.seq, first.seq]); + expect( + (await r.notificationActions.list({ outcomes: ['not_allowed'] })).map((a) => a.actor), + ).toEqual(['telegram:7']); + expect( + (await r.notificationActions.list({ beforeSeq: second.seq, limit: 5 })).map((a) => a.seq), + ).toEqual([first.seq]); + expect((await r.notificationActions.list({ notificationId: 'n-000000000001' })).length).toBe( + 2, + ); + expect(await r.notificationActions.get(first.seq)).toEqual({ ...press(), seq: first.seq }); + // The audit outlives its channel (no foreign key). + await r.notificationChannels.remove('nc-000000000001'); + expect((await r.notificationActions.list({})).length).toBe(3); + expect(await r.notificationActions.prune(215)).toBe(2); + }); + + it('keeps one cursor per key', async () => { + expect(await r.notificationCursors.get('telegram:1')).toBeNull(); + await r.notificationCursors.set('telegram:1', '10', 1); + await r.notificationCursors.set('telegram:1', '11', 2); + expect(await r.notificationCursors.get('telegram:1')).toBe('11'); + await r.notificationCursors.remove('telegram:1'); + expect(await r.notificationCursors.get('telegram:1')).toBeNull(); + }); + }); +} diff --git a/packages/core/test/persistence/conformance-operations.test.ts b/packages/core/test/persistence/conformance-operations.test.ts index 20f29ba..d918b35 100644 --- a/packages/core/test/persistence/conformance-operations.test.ts +++ b/packages/core/test/persistence/conformance-operations.test.ts @@ -427,6 +427,7 @@ describe('SchemaMigrationRepository', () => { [3, 'harness-identity'], [4, 'session-browser'], [5, 'notification-outbox'], + [6, 'notification-actions'], ]); expect(await t.repos.schemaMigrations.currentVersion()).toBe(SCHEMA_VERSION); }); diff --git a/packages/core/test/persistence/fixtures/generate-fixture.ts b/packages/core/test/persistence/fixtures/generate-fixture.ts index 83a1136..828c9d3 100644 --- a/packages/core/test/persistence/fixtures/generate-fixture.ts +++ b/packages/core/test/persistence/fixtures/generate-fixture.ts @@ -321,6 +321,36 @@ export async function seedFixture(uow: SqliteUnitOfWork): Promise { expiresAt: null, deletedAt: null, }); + // Schema v6: one act-button token, one audited press, one listener cursor (D-41, D-42). + await r.notificationActionTokens.insert([ + { + tokenHash: 'a'.repeat(64), + channelId: 'nc-fixture00001', + notificationId: 'n-fixture00002', + actionId: 'resolve', + op: 'attention.resolve', + args: { request_id: 'a-fixture00001', decision: 'resolve' }, + createdAt: at + 3_000, + expiresAt: at + 3_000 + 86_400_000, + usedAt: at + 4_000, + }, + ]); + await r.notificationActions.insert({ + at: at + 4_000, + channelId: 'nc-fixture00001', + channelName: 'phone', + channelKind: 'telegram', + notificationId: 'n-fixture00002', + actionId: 'resolve', + actionLabel: 'Mark resolved', + op: 'attention.resolve', + args: { request_id: 'a-fixture00001', decision: 'resolve' }, + actor: 'telegram:123456', + actorName: 'Fixture Person', + outcome: 'done', + detail: 'Marked resolved. The agent continues.', + }); + await r.notificationCursors.set('telegram:123456', '1001', at + 4_000); await r.preferences.set('local', 'theme', 'dark', at); await r.systemEvents.record({ eventId: 'e-sys-1', diff --git a/packages/core/test/persistence/fixtures/schema-v6.json b/packages/core/test/persistence/fixtures/schema-v6.json new file mode 100644 index 0000000..4211c13 --- /dev/null +++ b/packages/core/test/persistence/fixtures/schema-v6.json @@ -0,0 +1,5355 @@ +{ + "tables": [ + { + "name": "artifact_outbox", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "outbox_id", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "path", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "enqueued_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": "0", + "hidden": 0, + "name": "attempts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "last_error", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "auth_events", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "type", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "ip", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "user_agent", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "details_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "occurred_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_auth_events_time", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "occurred_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "auth_sessions", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "auth_session_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "token_hash", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "last_seen_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "expires_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "user_agent", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "ip", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "revoked_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_auth_sessions_principal", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "principal_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "auth_session_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "principal_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "principals", + "to": "principal_id" + } + ] + }, + { + "name": "blocked_requests", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "tool_event_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "url", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "domain", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "pattern", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "source", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "tool", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_blocked_domain", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 4, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "domain", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_blocked_pattern", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "pattern", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_blocked_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_blocked_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "credentials", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "credential_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "public_prefix", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "secret_hash", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "display", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": "'[]'", + "hidden": 0, + "name": "scopes_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "expires_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "last_used_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "revoked_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_credentials_prefix", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "public_prefix", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "credential_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_credentials_principal", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "principal_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "credential_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "principal_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "principals", + "to": "principal_id" + } + ] + }, + { + "name": "events", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "type", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "actor_kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "actor_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "occurred_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "trace_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "payload_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_events_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "seq", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_events_time", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "occurred_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + }, + { + "name": "idx_events_type_time", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "type", + "seqno": 0 + }, + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "occurred_at", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "grants", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "grant_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "token_hash", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "auth_session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "route", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "resource_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "expires_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "used_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [ + { + "from": "auth_session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "auth_sessions", + "to": "auth_session_id" + } + ] + }, + { + "name": "idempotency_keys", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "key", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "route", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "response_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "logs", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "level", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "module", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "msg", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "trace_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "span_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "request_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "principal", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "fields_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_logs_session", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_logs_trace", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "trace_id", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + }, + { + "name": "idx_logs_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "mcp_connections", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "connection_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "transport", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "mcp_session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "client_name", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "client_version", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "protocol_version", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "capabilities_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "agent_name", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "model", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "harness", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "ip", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "user_agent", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "connected_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "last_seen_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "closed_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 16, + "dflt_value": null, + "hidden": 0, + "name": "client_title", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 17, + "dflt_value": null, + "hidden": 0, + "name": "workspace", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 18, + "dflt_value": null, + "hidden": 0, + "name": "harness_source", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 19, + "dflt_value": null, + "hidden": 0, + "name": "model_source", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 20, + "dflt_value": null, + "hidden": 0, + "name": "harness_conflicts_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 21, + "dflt_value": null, + "hidden": 0, + "name": "meta_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_mcp_connections_open", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 14, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "last_seen_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "connection_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_mcp_connections_seen", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 14, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "last_seen_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "connection_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "meta", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "key", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "value", + "notnull": 1, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "notification_action_tokens", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "token_hash", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "channel_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "notification_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "action_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "op", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": "'{}'", + "hidden": 0, + "name": "args_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "expires_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "used_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_notification_action_tokens_channel", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "channel_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "token_hash", + "seqno": 1 + } + ] + }, + { + "name": "idx_notification_action_tokens_expiry", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "expires_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "token_hash", + "seqno": 1 + } + ] + }, + { + "name": "idx_notification_action_tokens_notification", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "notification_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "token_hash", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "notification_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "notifications", + "to": "notification_id" + }, + { + "from": "channel_id", + "id": 1, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "notification_channels", + "to": "channel_id" + } + ] + }, + { + "name": "notification_actions", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 0, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "channel_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "channel_name", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "channel_kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "notification_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "action_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "action_label", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "op", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": "'{}'", + "hidden": 0, + "name": "args_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "actor", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "actor_name", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "outcome", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "detail", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_notification_actions_at", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + }, + { + "name": "idx_notification_actions_channel", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "channel_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "seq", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_notification_actions_notification", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "notification_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "seq", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "notification_channel_messages", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "channel_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "notification_id", + "notnull": 1, + "pk": 2, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "thread", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "message_ref_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "last_revision", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "sent_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "expires_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "deleted_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_notification_channel_messages_expiry", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "expires_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "channel_id", + "seqno": 1 + }, + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_notification_channel_messages_thread", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "channel_id", + "seqno": 0 + }, + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "thread", + "seqno": 1 + }, + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "sent_at", + "seqno": 2 + }, + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 3 + } + ] + } + ], + "foreignKeys": [ + { + "from": "notification_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "notifications", + "to": "notification_id" + }, + { + "from": "channel_id", + "id": 1, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "notification_channels", + "to": "channel_id" + } + ] + }, + { + "name": "notification_channels", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "channel_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "name", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "mode", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": "'db'", + "hidden": 0, + "name": "source", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": "'active'", + "hidden": 0, + "name": "status", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": "'{}'", + "hidden": 0, + "name": "target_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": "'{}'", + "hidden": 0, + "name": "secret_refs_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": "'{}'", + "hidden": 0, + "name": "rules_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": "0", + "hidden": 0, + "name": "failure_count", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "last_error", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "last_ok_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "last_failure_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "notification_cursors", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "cursor_key", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "value", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "notification_deliveries", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 0, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "channel_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "notification_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "revision", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "op", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "status", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "reason", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": "0", + "hidden": 0, + "name": "attempts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "next_attempt_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "last_error", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "duration_ms", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "message_ref_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_notification_deliveries_channel", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "channel_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "seq", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_notification_deliveries_due", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "next_attempt_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "seq", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_notification_deliveries_idem", + "unique": 1, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "channel_id", + "seqno": 0 + }, + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "notification_id", + "seqno": 1 + }, + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "revision", + "seqno": 2 + }, + { + "cid": 4, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "op", + "seqno": 3 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 4 + } + ] + }, + { + "name": "idx_notification_deliveries_notification", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "notification_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "seq", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_notification_deliveries_sending", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 13, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "updated_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + }, + { + "name": "idx_notification_deliveries_updated", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 13, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "updated_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "notification_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "notifications", + "to": "notification_id" + }, + { + "from": "channel_id", + "id": 1, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "notification_channels", + "to": "channel_id" + } + ] + }, + { + "name": "notifications", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "notification_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "type", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "title", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "body", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "target", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "source_event_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "read_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "dismissed_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": "0", + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": "1", + "hidden": 0, + "name": "count", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "group_key", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "category", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 16, + "dflt_value": null, + "hidden": 0, + "name": "severity", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 17, + "dflt_value": null, + "hidden": 0, + "name": "state", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 18, + "dflt_value": "1", + "hidden": 0, + "name": "revision", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 19, + "dflt_value": null, + "hidden": 0, + "name": "thread", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 20, + "dflt_value": null, + "hidden": 0, + "name": "message_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_notifications_group", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 13, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "group_key", + "seqno": 0 + }, + { + "cid": 11, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "updated_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_notifications_inbox", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "principal_id", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_notifications_thread", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 19, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "thread", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_notifications_updated", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "principal_id", + "seqno": 0 + }, + { + "cid": 11, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "updated_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "operator_actions", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "action", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "resource_kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "resource_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "details_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "occurred_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_operator_actions_time", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "occurred_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "operator_requests", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "request_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "owner", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "reason", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "mode", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "entry_name", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "tool", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "tool_event_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "page_url", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "options_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "idempotency_key", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "status", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "message", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "resolved_by", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "resolution_reason", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 16, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 17, + "dflt_value": null, + "hidden": 0, + "name": "deadline_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 18, + "dflt_value": null, + "hidden": 0, + "name": "resolved_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_operator_requests_history", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "kind", + "seqno": 0 + }, + { + "cid": 16, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "request_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_operator_requests_idem", + "unique": 1, + "partial": 1, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 11, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "idempotency_key", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "request_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_operator_requests_open", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "kind", + "seqno": 0 + }, + { + "cid": 16, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "request_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_operator_requests_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 16, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "request_id", + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "pages", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "tab_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "url", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "title", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "domain", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "category", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_pages_category_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 6, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "category", + "seqno": 0 + }, + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_pages_domain", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "domain", + "seqno": 0 + }, + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_pages_session_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_pages_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "preferences", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "key", + "notnull": 1, + "pk": 2, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "value_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [ + { + "from": "principal_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "principals", + "to": "principal_id" + } + ] + }, + { + "name": "principals", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "display", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": "0", + "hidden": 0, + "name": "must_change_password", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "disabled_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "resource_samples", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 2, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "cpu_pct", + "notnull": 0, + "pk": 0, + "type": "REAL" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "rss_bytes", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "host_free_bytes", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "schema_migrations", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "version", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "name", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "applied_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "duration_ms", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "app_version", + "notnull": 1, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "screenshots", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "path", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "content_type", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "width", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "height", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "size_bytes", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_screenshots_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + }, + { + "from": "event_id", + "id": 1, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "tool_calls", + "to": "event_id" + } + ] + }, + { + "name": "sessions", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "slug", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "owner", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "connection_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": "'chromium'", + "hidden": 0, + "name": "engine", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "channel", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "headless", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": "0", + "hidden": 0, + "name": "incognito", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "persistence_mode", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": "0", + "hidden": 0, + "name": "disable_evaluate", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": "1", + "hidden": 0, + "name": "vault_enabled", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "stealth", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "fingerprint", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "humanize", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "identity_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 16, + "dflt_value": null, + "hidden": 0, + "name": "proxy_label", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 17, + "dflt_value": null, + "hidden": 0, + "name": "state", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 18, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 19, + "dflt_value": null, + "hidden": 0, + "name": "launched_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 20, + "dflt_value": null, + "hidden": 0, + "name": "last_activity_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 21, + "dflt_value": null, + "hidden": 0, + "name": "lease_expires_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 22, + "dflt_value": null, + "hidden": 0, + "name": "lease_paused_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 23, + "dflt_value": null, + "hidden": 0, + "name": "closed_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 24, + "dflt_value": null, + "hidden": 0, + "name": "closed_reason", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 25, + "dflt_value": null, + "hidden": 0, + "name": "archived_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 26, + "dflt_value": null, + "hidden": 0, + "name": "last_url", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 27, + "dflt_value": null, + "hidden": 0, + "name": "launch_ms", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 28, + "dflt_value": null, + "hidden": 0, + "name": "config_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 29, + "dflt_value": null, + "hidden": 0, + "name": "harness", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 30, + "dflt_value": null, + "hidden": 0, + "name": "sandboxed", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 31, + "dflt_value": null, + "hidden": 0, + "name": "browser_version", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_sessions_archived", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 25, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "archived_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_closed", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 23, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "closed_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_created", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 18, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_harness", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 29, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "harness", + "seqno": 0 + }, + { + "cid": 18, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_sessions_lease", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 21, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "lease_expires_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_open", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 17, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "state", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_owner", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "owner", + "seqno": 0 + }, + { + "cid": 18, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "connection_id", + "id": 0, + "match": "NONE", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "seq": 0, + "table": "mcp_connections", + "to": "connection_id" + } + ] + }, + { + "name": "system_events", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "code", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "severity", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "message", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "details_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "first_seen_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "last_seen_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": "1", + "hidden": 0, + "name": "count", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "resolved_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_system_events_open", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "code", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "tool_calls", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "connection_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "tool", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "tab_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "args_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "ok", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "error_code", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "error_message", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "result_text", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "result_size_bytes", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "duration_ms", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "trace_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "span_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_tool_calls_error", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "error_code", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_tool_calls_session_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_tool_calls_tool_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "tool", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_tool_calls_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "vault_access", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "tool_event_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "entry_name", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "handle", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "result", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "reason", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "evaluate_enabled", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "page_url", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "origin_check", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "details_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_vault_access_entry", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "entry_name", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_vault_access_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_vault_access_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "vault_bindings", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "handle", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "title", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "item_name", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": "''", + "hidden": 0, + "name": "item_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "group_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": "'[]'", + "hidden": 0, + "name": "allowed_origins_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": "'[]'", + "hidden": 0, + "name": "authorized_principals_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": "'[]'", + "hidden": 0, + "name": "authorized_session_slugs_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": "0", + "hidden": 0, + "name": "allow_all_sessions", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": "0", + "hidden": 0, + "name": "redact_username", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": "0", + "hidden": 0, + "name": "require_no_evaluate", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": "0", + "hidden": 0, + "name": "dashboard_confirm", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": "1", + "hidden": 0, + "name": "version", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_vault_bindings_group", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "group_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "handle", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "vault_group_policies", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "group_key", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "group_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "access_mode", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": "0", + "hidden": 0, + "name": "allow_all_sessions", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": "'[]'", + "hidden": 0, + "name": "session_slug_globs_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": "'[]'", + "hidden": 0, + "name": "authorized_principals_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": "0", + "hidden": 0, + "name": "dashboard_confirm", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": "0", + "hidden": 0, + "name": "require_no_evaluate", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": "0", + "hidden": 0, + "name": "redact_username", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": "1", + "hidden": 0, + "name": "version", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [] + } + ], + "triggers": [], + "views": [] +} diff --git a/packages/core/test/persistence/fixtures/v6.db b/packages/core/test/persistence/fixtures/v6.db new file mode 100644 index 0000000..e9545a9 Binary files /dev/null and b/packages/core/test/persistence/fixtures/v6.db differ diff --git a/packages/core/test/persistence/notification-actions-migration.test.ts b/packages/core/test/persistence/notification-actions-migration.test.ts new file mode 100644 index 0000000..bdf9519 --- /dev/null +++ b/packages/core/test/persistence/notification-actions-migration.test.ts @@ -0,0 +1,95 @@ +/** @module test/persistence/notification-actions-migration.test — schema v6 (`0006-notification-actions`) over a v5 database: three new, empty tables, the minimum reader unchanged, the outcome CHECK and the token cascades, and `purge` inventorying a v5 database (03 §7; D-41). */ + +import { describe, expect, it } from 'bun:test'; +import { copyFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { SqliteMaintenanceService } from '../../src/infra/persistence/maintenance.ts'; +import { SCHEMA_VERSION } from '../../src/infra/persistence/migrations/index.ts'; +import { openDatabase } from '../../src/infra/persistence/open.ts'; +import { FakeClock, FakeLogger, tempDir } from './helpers.ts'; + +const FIXTURES = join(import.meta.dir, 'fixtures'); + +async function openCopy(fixture: string, readOnly = false) { + const dir = tempDir(); + const path = join(dir.path, 'browserhive.db'); + copyFileSync(join(FIXTURES, fixture), path); + const handle = await openDatabase({ + path, + dataDir: dir.path, + appVersion: 'test', + clock: new FakeClock(), + logger: new FakeLogger(), + ...(readOnly && { readOnly: true }), + }); + return { dir, handle }; +} + +describe('migration 0006-notification-actions', () => { + it('upgrades a v5 database with empty act-button tables and keeps the minimum reader', async () => { + const { dir, handle } = await openCopy('v5.db'); + try { + expect(handle.schemaVersion).toBe(SCHEMA_VERSION); + for (const table of [ + 'notification_action_tokens', + 'notification_actions', + 'notification_cursors', + ]) { + const row = handle.raw.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get(); + expect(row?.n).toBe(0); + } + const reader = handle.raw + .query<{ value: string }, []>("SELECT value FROM meta WHERE key = 'min_reader_version'") + .get(); + expect(reader?.value).toBe('1'); + } finally { + await handle.close(); + dir.dispose(); + } + }); + + it('checks the outcome and drops tokens with their notification', async () => { + const { dir, handle } = await openCopy('v6.db'); + try { + expect(() => + handle.raw.run( + "INSERT INTO notification_actions (at, channel_id, channel_name, channel_kind, action_id, op, actor, outcome) VALUES (1, 'c', 'n', 'telegram', 'a', 'attention.resolve', 'telegram:1', 'maybe')", + ), + ).toThrow(); + const before = handle.raw + .query<{ n: number }, []>('SELECT COUNT(*) AS n FROM notification_action_tokens') + .get(); + expect(before?.n).toBe(1); + handle.raw.run("DELETE FROM notifications WHERE notification_id = 'n-fixture00002'"); + const after = handle.raw + .query<{ n: number }, []>('SELECT COUNT(*) AS n FROM notification_action_tokens') + .get(); + expect(after?.n).toBe(0); + const audit = handle.raw + .query<{ n: number }, []>('SELECT COUNT(*) AS n FROM notification_actions') + .get(); + expect(audit?.n).toBe(1); + } finally { + await handle.close(); + dir.dispose(); + } + }); + + it('purge inventories a v5 database without failing on the new tables', async () => { + const { dir, handle } = await openCopy('v5.db', true); + try { + const maintenance = new SqliteMaintenanceService({ + handle, + clock: new FakeClock(), + logger: new FakeLogger(), + appVersion: 'test', + }); + const tables = (await maintenance.inventory()).tables.map((t) => t.table); + expect(tables).toContain('notification_channels'); + expect(tables).not.toContain('notification_actions'); + } finally { + await handle.close(); + dir.dispose(); + } + }); +}); diff --git a/packages/dashboard/src/features/notifications/NotificationsNav.tsx b/packages/dashboard/src/features/notifications/NotificationsNav.tsx index b7b0608..9a75ccd 100644 --- a/packages/dashboard/src/features/notifications/NotificationsNav.tsx +++ b/packages/dashboard/src/features/notifications/NotificationsNav.tsx @@ -1,4 +1,4 @@ -/** @module features/notifications/NotificationsNav — the Notifications area's section nav (Inbox · Channels · Delivery log): underline tabs made of real links under the page header (spec 04 §12.11.1) */ +/** @module features/notifications/NotificationsNav — the Notifications area's section nav (Inbox · Channels · Delivery log · Actions): underline tabs made of real links under the page header (spec 04 §12.11.1) */ import { Link, useRouterState } from '@tanstack/react-router'; import { ICONS, type IconName } from '@/lib/icons.ts'; import { cn } from '@/lib/utils.ts'; @@ -11,12 +11,14 @@ const SECTIONS: readonly { { to: '/notifications', label: 'Inbox', icon: 'inbox' }, { to: '/notifications/channels', label: 'Channels', icon: 'channels' }, { to: '/notifications/log', label: 'Delivery log', icon: 'deliveryLog' }, + { to: '/notifications/actions', label: 'Actions', icon: 'actions' }, ]; /** Which section a pathname belongs to. */ export function activeSection(pathname: string): string { if (pathname.startsWith('/notifications/channels')) return '/notifications/channels'; if (pathname.startsWith('/notifications/log')) return '/notifications/log'; + if (pathname.startsWith('/notifications/actions')) return '/notifications/actions'; return '/notifications'; } diff --git a/packages/dashboard/src/features/notifications/channels/ChannelCard.tsx b/packages/dashboard/src/features/notifications/channels/ChannelCard.tsx index 3bd74bf..6eac7a3 100644 --- a/packages/dashboard/src/features/notifications/channels/ChannelCard.tsx +++ b/packages/dashboard/src/features/notifications/channels/ChannelCard.tsx @@ -1,4 +1,4 @@ -/** @module features/notifications/channels/ChannelCard — one channel as a whole-card link: platform mark, name, status, where it sends, what it sends, secret variables (set / missing, never values), last delivery and 24 h counts; Send test (result inline), Pause/Resume, and Edit / Duplicate / Delete in a menu (not for startup channels, which are read-only; D-39) */ +/** @module features/notifications/channels/ChannelCard — one channel as a whole-card link: platform mark, name, status, where it sends, what it sends, whether answers from the chat reach BrowserHive (the press listener's state, D-41), secret variables (set / missing, never values), last delivery and 24 h counts; Send test (result inline), Pause/Resume, and Edit / Duplicate / Delete in a menu (not for startup channels, which are read-only; D-39) */ import type { ChannelTestResponse, ChannelView } from '@browserhive/contracts/http'; import { deliveryReasonText } from '@browserhive/contracts/notifications'; import { isPlainClick, useHrefNavigate } from '@/components/shared/DataTableBody.tsx'; @@ -16,9 +16,9 @@ import { Spinner } from '@/components/ui/spinner.tsx'; import { formatNumber } from '@/lib/format/bytes.ts'; import { formatMs } from '@/lib/format/time.ts'; import { ICONS } from '@/lib/icons.ts'; -import { CHANNEL_STATUS, DELIVERY_STATUS } from '@/lib/status-registry.ts'; +import { CHANNEL_STATUS, DELIVERY_STATUS, LISTENER_STATE } from '@/lib/status-registry.ts'; import { cn } from '@/lib/utils.ts'; -import { rulesSummary } from './model.ts'; +import { channelWhere, rulesSummary } from './model.ts'; import { PlatformMark, platformOf } from './platforms.tsx'; /** The outcome of the last test send of a card. */ @@ -43,6 +43,60 @@ export interface ChannelCardProps { readonly busy?: boolean; } +/** "Answers from the chat": whether presses reach BrowserHive (only with act buttons on). */ +function AnswersLine({ channel, now }: { readonly channel: ChannelView; readonly now: number }) { + if (channel.rules.act_buttons !== true) return null; + const Answer = ICONS.answer; + const c = channel.connection; + const allow = channel.rules.allow_list?.length ?? 0; + const who = + channel.kind === 'telegram' || channel.kind === 'discord' + ? allow === 0 + ? 'no allowed people yet' + : `${allow} ${allow === 1 ? 'person' : 'people'} may answer` + : null; + return ( +
    +
    + ); +} + function TestResult({ test }: { readonly test: TestState }) { if (test.phase === 'idle') return null; if (test.phase === 'sending') { @@ -162,9 +216,13 @@ export function ChannelCard({

    {info.label} - {channel.mode !== null && channel.kind === 'discord' ? ` ${channel.mode}` : ''} + {channel.mode !== null && channel.kind === 'discord' + ? channel.mode === 'bot' + ? ' bot' + : ' webhook' + : ''} {' · '} - {channel.target_hint} + {channelWhere(channel)}

    @@ -233,6 +291,7 @@ export function ChannelCard({ {channel.problem}

    ) : null} +
      {channel.secrets.map((s) => (
    • { await until(() => findCard('team') === null); }); + it('shows who answers from the chat and the press listener', async () => { + const family = byName('family'); + const team = byName('team'); + mount({ + 'GET /channels': { + ...LIST, + data: [ + { + ...family, + rules: { ...family.rules, act_buttons: true, allow_list: ['1', '2'] }, + connection: { state: 'connected', since: 1, detail: null }, + }, + { + ...team, + mode: 'bot', + target: { channel_id: '9', channel_name: 'alerts', guild_name: 'Home' }, + rules: { act_buttons: true }, + connection: { state: 'offline', since: 1, detail: 'Discord refused the token (401)' }, + }, + ], + }, + }); + await until(() => findCard('team') !== null); + expect(within(card('family')).getByText('Answers from the chat')).toBeDefined(); + expect(within(card('family')).getByText('2 people may answer')).toBeDefined(); + expect(within(card('team')).getByText('Discord refused the token (401)')).toBeDefined(); + expect(card('team').textContent).toContain('Discord bot · #alerts in Home'); + }); + it('explains channels when there are none', async () => { mount({ 'GET /channels': { data: [], now: 1 } }); expect(await screen.findByText('Get notified on your phone')).toBeDefined(); diff --git a/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.test.tsx b/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.test.tsx new file mode 100644 index 0000000..04d8067 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.test.tsx @@ -0,0 +1,179 @@ +/** @module features/notifications/channels/actions/ActionsPage.test — the act-button audit: rows with the outcome and its sentence linking to the delivery log, the outcome filter in the query, "Allow this person" on a refused press (confirm, read the channel, save the allow-list with the id appended; disabled with the `allow=` hint on a startup channel; only for Telegram and Discord pressers), a live `action.recorded`, the empty state, axe clean */ +import { describe, expect, it } from 'bun:test'; +import type { ActionRow, ChannelView } from '@browserhive/contracts/http'; +import { ChannelsResponse } from '@browserhive/contracts/http'; +import { CAPTURED } from '../../../../../test/fixtures/channels.ts'; +import { expectNoA11yViolations } from '../../../../../test/helpers/axe.ts'; +import { + envelope, + type RecordedRequest, + renderPage, +} from '../../../../../test/helpers/page-harness.tsx'; +import { act, fireEvent, screen, waitFor } from '../../../../../test/helpers/render.tsx'; +import { actionsSearch } from '../search.ts'; +import { ActionsPage, actorParts, allowableId } from './ActionsPage.tsx'; + +const LIST = ChannelsResponse.parse(CAPTURED.channels); +const family = LIST.data.find((c) => c.name === 'family') as ChannelView; +const PHONE: ChannelView = { + ...family, + rules: { ...family.rules, act_buttons: true, allow_list: ['1111'] }, +}; +const OPS: ChannelView = { + ...(LIST.data.find((c) => c.name === 'team') as ChannelView), + name: 'ops-bot', + mode: 'bot', + source: 'startup', + rules: { act_buttons: true }, +}; + +function row(overrides: Partial = {}): ActionRow { + return { + seq: 1, + at: 1_700_000_000_000, + channel_id: PHONE.channel_id, + channel_name: PHONE.name, + channel_kind: 'telegram', + notification_id: 'n-000000000001', + notification_title: 'Attention requested on example.com', + action_id: 'resolve', + action_label: 'Mark resolved', + op: 'attention.resolve', + actor: 'telegram:1111', + actor_name: 'Amir', + outcome: 'done', + detail: 'Marked resolved.', + ...overrides, + }; +} + +const REFUSED = row({ + seq: 3, + actor: 'telegram:2222', + actor_name: 'Sam', + outcome: 'not_allowed', + detail: 'You are not allowed to answer here yet.', +}); +const REFUSED_STARTUP = row({ + seq: 2, + channel_id: OPS.channel_id, + channel_name: OPS.name, + channel_kind: 'discord', + actor: 'discord:3333', + actor_name: null, + outcome: 'not_allowed', +}); + +function mount(rows: readonly ActionRow[], routes: Record = {}, url = '/') { + return renderPage({ + path: '/', + component: ActionsPage, + validateSearch: (s) => actionsSearch.parse(s), + url, + routes: { + 'GET /channels': { ...LIST, data: [PHONE, OPS] }, + 'GET /channels/actions': (req: RecordedRequest) => { + const outcome = req.query.get('outcome'); + return envelope( + outcome === null ? rows : rows.filter((r) => outcome.split(',').includes(r.outcome)), + ); + }, + ...routes, + }, + }); +} + +describe('ActionsPage', () => { + it('names the presser and the id to allow', () => { + expect(actorParts({ actor: 'telegram:42', actor_name: null })).toEqual({ + name: 'Telegram user', + platform: 'Telegram', + id: '42', + }); + expect(actorParts({ actor: 'ntfy:topic-b', actor_name: null }).platform).toBe( + 'ntfy reply topic', + ); + expect(allowableId({ actor: 'discord:3333', outcome: 'not_allowed' })).toBe('3333'); + expect(allowableId({ actor: 'discord:3333', outcome: 'done' })).toBeNull(); + expect(allowableId({ actor: 'ntfy:topic-b', outcome: 'not_allowed' })).toBeNull(); + }); + + it('lists presses with their outcome, linked to the delivery log, and is accessible', async () => { + const view = mount([REFUSED, REFUSED_STARTUP, row()]); + expect(await screen.findByText('Sam')).toBeDefined(); + expect(screen.getAllByText('Refused: not on the allow-list.').length).toBe(2); + expect(screen.getByText('Marked resolved.')).toBeDefined(); + const links = screen.getAllByRole('link', { name: /open its deliveries/ }); + expect(links.length).toBe(3); + expect(links[0]?.getAttribute('href')).toContain('/notifications/log?notification='); + await expectNoA11yViolations(view.container); + }); + + it('allows a refused presser after a confirmation', async () => { + let saved: unknown = null; + const view = mount([REFUSED], { + [`GET /channels/${PHONE.channel_id}`]: { channel: PHONE }, + [`PATCH /channels/${PHONE.channel_id}`]: (req: RecordedRequest) => { + saved = req.body; + return { channel: { ...PHONE, rules: { ...PHONE.rules, allow_list: ['1111', '2222'] } } }; + }, + }); + const allow = await screen.findByRole('button', { name: /Allow this person/ }); + await act(async () => { + fireEvent.click(allow); + }); + expect(await screen.findByText('Allow Sam to answer on family?')).toBeDefined(); + const buttons = screen.getAllByRole('button', { name: 'Allow this person' }); + await act(async () => { + fireEvent.click(buttons[buttons.length - 1] as HTMLElement); + }); + await waitFor(() => expect(saved).not.toBeNull()); + expect(saved).toEqual({ rules: { ...PHONE.rules, allow_list: ['1111', '2222'] } }); + expect(view.requests.some((r) => r.method === 'GET' && r.path.endsWith(PHONE.channel_id))).toBe( + true, + ); + expect((await screen.findAllByText('Sam can now answer on family')).length).toBeGreaterThan(0); + expect(await screen.findByText('Allowed now')).toBeDefined(); + }); + + it('points a startup channel to its allow= parameter', async () => { + mount([REFUSED_STARTUP]); + const allow = await screen.findByRole('button', { name: /Allow this person/ }); + expect(allow.hasAttribute('disabled')).toBe(true); + expect(allow.getAttribute('title')).toContain('allow=3333'); + }); + + it('offers no allow for ntfy or other outcomes', async () => { + mount([ + row({ seq: 5, actor: 'ntfy:topic-b', actor_name: null, outcome: 'not_allowed' }), + row({ seq: 4, outcome: 'expired', detail: null }), + ]); + expect(await screen.findByText('Someone with the topic')).toBeDefined(); + expect(screen.queryByRole('button', { name: /Allow this person/ })).toBeNull(); + }); + + it('filters by outcome through the query', async () => { + const view = mount([REFUSED, row()], {}, '/?outcome=not_allowed'); + expect(await screen.findByText('Sam')).toBeDefined(); + expect(screen.queryByText('Amir')).toBeNull(); + expect( + view.requests.some( + (r) => r.path.endsWith('/channels/actions') && r.query.get('outcome') === 'not_allowed', + ), + ).toBe(true); + }); + + it('adds a live press to the top', async () => { + const view = mount([row()]); + expect(await screen.findByText('Amir')).toBeDefined(); + await waitFor(() => expect(view.sockets.length).toBe(1)); + view.connect(); + view.emit('channels', { type: 'action.recorded', action: REFUSED }); + expect(await screen.findByText('Sam')).toBeDefined(); + }); + + it('explains the audit when nothing was pressed yet', async () => { + mount([]); + expect(await screen.findByRole('link', { name: /Go to channels/ })).toBeDefined(); + }); +}); diff --git a/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.tsx b/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.tsx new file mode 100644 index 0000000..0a425bf --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.tsx @@ -0,0 +1,359 @@ +/** @module features/notifications/channels/actions/ActionsPage — `/notifications/actions`: the audit of act-button presses newest first (time, channel, the notification and its button, who pressed, the outcome with its sentence; "Allow this person" on a press refused by the allow-list), filters in the URL, live through the `channels` topic; a row opens the delivery log of its notification (spec 04 §12.11.1, D-41) */ +import type { ActionRow, ChannelView } from '@browserhive/contracts/http'; +import { ACTION_OUTCOME_TEXT } from '@browserhive/contracts/notifications'; +import { Link } from '@tanstack/react-router'; +import { useConfirm } from '@/app/providers/ConfirmProvider.tsx'; +import { useTopic } from '@/app/providers/SocketProvider.tsx'; +import { useToast } from '@/app/providers/ToastProvider.tsx'; +import { DataPanel } from '@/components/shared/DataPanel.tsx'; +import { EmptyState } from '@/components/shared/EmptyState.tsx'; +import { FilterBar } from '@/components/shared/FilterBar.tsx'; +import { PageHeader } from '@/components/shared/PageHeader.tsx'; +import { Pagination } from '@/components/shared/Pagination.tsx'; +import { RelativeTime } from '@/components/shared/RelativeTime.tsx'; +import { Panel } from '@/components/shared/Section.tsx'; +import { TonePill } from '@/components/shared/StatusBadge.tsx'; +import { Button, buttonVariants } from '@/components/ui/button.tsx'; +import { ListSkeleton } from '@/features/overview/components/ListSkeleton.tsx'; +import { toAppError } from '@/lib/api/errors.ts'; +import { ICONS } from '@/lib/icons.ts'; +import { useSearchState } from '@/lib/search/use-search-state.ts'; +import { ACTION_OUTCOME } from '@/lib/status-registry.ts'; +import { cn } from '@/lib/utils.ts'; +import { NotificationsNav } from '../../NotificationsNav.tsx'; +import { useActionAudit, useAllowPresser, useChannels } from '../api.ts'; +import { PlatformMark } from '../platforms.tsx'; +import type { ActionsSearch } from '../search.ts'; + +const OUTCOMES = [ + 'done', + 'failed', + 'not_allowed', + 'used', + 'expired', + 'stale', + 'wrong_channel', + 'disabled', +] as const; + +const OP_LABEL: Readonly> = { + 'attention.resolve': 'attention request', + 'vault.confirm.resolve': 'vault fill', + 'session.close': 'session', + 'session.extend_lease': 'session lease', +}; + +const PLATFORM_LABEL: Readonly> = { + telegram: 'Telegram', + discord: 'Discord', + ntfy: 'ntfy', +}; + +/** Who pressed, as a name and a coordinate ("Telegram · 123456789", "ntfy reply topic"). */ +export function actorParts(row: Pick): { + readonly name: string; + readonly platform: string; + readonly id: string | null; +} { + const colon = row.actor.indexOf(':'); + const platform = colon < 0 ? row.actor : row.actor.slice(0, colon); + const id = colon < 0 ? null : row.actor.slice(colon + 1); + const label = PLATFORM_LABEL[platform] ?? platform; + if (platform === 'ntfy') { + return { + name: row.actor_name ?? 'Someone with the topic', + platform: 'ntfy reply topic', + id: null, + }; + } + return { name: row.actor_name ?? `${label} user`, platform: label, id }; +} + +/** The id a refused presser can be allowed with (`telegram:`, `discord:`), or `null`. */ +export function allowableId(row: Pick): string | null { + if (row.outcome !== 'not_allowed') return null; + return /^(?:telegram|discord):(\d{1,21})$/.exec(row.actor)?.[1] ?? null; +} + +/** "Allow this person" on a refused press (D-41): adds the presser's id to the channel's allow-list. */ +function AllowButton({ + row, + channel, +}: { + readonly row: ActionRow; + readonly channel: ChannelView | undefined; +}) { + const id = allowableId(row); + const confirm = useConfirm(); + const toast = useToast(); + const allow = useAllowPresser(); + if (id === null || channel === undefined) return null; + const who = row.actor_name ?? id; + const Check = ICONS.check; + const UserCheck = ICONS.allowList; + if ((channel.rules.allow_list ?? []).includes(id)) { + return ( + + + ); + } + const startup = channel.source === 'startup'; + const onClick = async () => { + const ok = await confirm({ + title: `Allow ${who} to answer on ${channel.name}?`, + description: `${who} (${id}) is added to the channel's allowed people and can then press Approve, Reject and Mark resolved in the chat. You can remove them in Channels → ${channel.name} → Answer from the chat.`, + confirmLabel: 'Allow this person', + }); + if (!ok) return; + allow.mutate( + { channelId: channel.channel_id, userId: id }, + { + onSuccess: () => + toast.success({ + title: `${who} can now answer on ${channel.name}`, + description: 'Added to the allowed people. Their next press works.', + }), + onError: (error) => toast.fromError(toAppError(error), 'Could not add them'), + }, + ); + }; + return ( + + ); +} + +function Row({ + row, + channel, +}: { + readonly row: ActionRow; + readonly channel: ChannelView | undefined; +}) { + const entry = ACTION_OUTCOME[row.outcome]; + const who = actorParts(row); + const Chevron = ICONS.chevronRight; + const sentence = + row.outcome === 'not_allowed' + ? 'Refused: not on the allow-list.' + : (row.detail ?? ACTION_OUTCOME_TEXT[row.outcome] ?? null); + const linked = row.notification_id !== null; + return ( +
    • + {linked ? ( + + + {row.action_label ?? row.action_id} on {row.notification_title ?? 'a notification'},{' '} + {entry.label}: open its deliveries + + + ) : null} +
      + + + + + + {row.channel_name} + + + + {row.notification_title ?? 'A notification that is gone'} + + + Pressed{' '} + {row.action_label ?? row.action_id} + {' · '} + {OP_LABEL[row.op] ?? row.op} + + + + {who.name} + + {who.platform} + {who.id !== null ? ( + <> + {' · '} + {who.id} + + ) : null} + + + + + {sentence !== null ? ( + + {sentence} + + ) : null} + + + {linked ? ( +
      +
    • + ); +} + +/** Notifications › Actions. */ +export function ActionsPage() { + const { search, set, clear } = useSearchState(); + const channels = useChannels(); + const list = useActionAudit( + { channel: search.channel, outcome: search.outcome }, + search.page, + search.ps, + ); + useTopic('channels'); + const filtered = search.channel !== undefined || search.outcome !== undefined; + const Settings = ICONS.channels; + return ( +
      + } + /> + ({ + value: c.channel_id, + label: c.name, + })), + }, + ]} + chips={[ + { + param: 'outcome', + label: 'Outcome', + options: OUTCOMES.map((value) => ({ value, count: 0 })), + selected: search.outcome ?? [], + counts: false, + format: (v) => ACTION_OUTCOME[v as keyof typeof ACTION_OUTCOME]?.label ?? v, + }, + ]} + {...(list.data?.page.total !== undefined && { matching: list.data.page.total })} + onChange={(param, value) => set({ [param]: value })} + onClear={() => clear()} + /> + + + + } + isEmpty={(page) => page.data.length === 0} + empty={ + filtered ? ( + clear()} + /> + ) : ( + + +
      + ); +} diff --git a/packages/dashboard/src/features/notifications/channels/api.ts b/packages/dashboard/src/features/notifications/channels/api.ts index fb11066..3ef1920 100644 --- a/packages/dashboard/src/features/notifications/channels/api.ts +++ b/packages/dashboard/src/features/notifications/channels/api.ts @@ -1,4 +1,6 @@ -/** @module features/notifications/channels/api — notification channel queries and mutations over `/channels` (list, detail, create/update/delete, pause/resume, test, preview, env check, Telegram connect, delivery log) and `GET /system/public-url`; the `channels` WS topic patches the caches through the bridge (spec 03 §4.8.1, spec 04 §12.11.1) */ +/** @module features/notifications/channels/api — notification channel queries and mutations over `/channels` (list, detail, create/update/delete, pause/resume, test, preview, env check, Telegram connect, delivery log, Discord bot setup and account link, the act-button audit) and `GET /system/public-url`; the `channels` WS topic patches the caches through the bridge (spec 03 §4.8.1, spec 04 §12.11.1) */ + +import type { NotificationActionOutcome } from '@browserhive/contracts/enums'; import type { ChannelInput, ChannelPatch, @@ -230,3 +232,109 @@ export function useDelivery(seq: number | null) { enabled: seq !== null, }); } + +/** `POST /channels/discord/bot`: who the bot is, its invite link and its servers (disabled without a set variable). */ +export function useDiscordBot(tokenEnv: string, enabled: boolean) { + const api = useApi(); + return useQuery({ + queryKey: keys.channels.discordBot(tokenEnv), + queryFn: () => api.getDiscordBot({ body: { token_env: tokenEnv } }), + enabled: enabled && tokenEnv !== '', + retry: false, + staleTime: 60_000, + }); +} + +/** `POST /channels/discord/channels`: the text channels of one server. */ +export function useDiscordChannels(tokenEnv: string, guildId: string | null) { + const api = useApi(); + return useQuery({ + queryKey: keys.channels.discordChannels(tokenEnv, guildId ?? ''), + queryFn: () => + api.listDiscordChannels({ body: { token_env: tokenEnv, guild_id: guildId ?? '' } }), + enabled: tokenEnv !== '' && guildId !== null && guildId !== '', + retry: false, + staleTime: 60_000, + }); +} + +/** `POST /channels/discord/connect`: the bot posts "This is me" and the server waits for the press. */ +export function useStartDiscordConnect() { + const api = useApi(); + return useMutation({ + mutationFn: (input: { readonly tokenEnv: string; readonly channelId: string }) => + api.startDiscordConnect({ + body: { token_env: input.tokenEnv, channel_id: input.channelId }, + }), + }); +} + +/** `GET /channels/discord/connect/{id}`, polled every 2 s while waiting. */ +export function useDiscordConnect(connectId: string | null) { + const api = useApi(); + return useQuery({ + queryKey: keys.channels.discordConnect(connectId ?? ''), + queryFn: () => api.getDiscordConnect({ params: { connect_id: connectId ?? '' } }), + enabled: connectId !== null, + refetchInterval: (query) => (query.state.data?.status === 'waiting' ? 2_000 : false), + }); +} + +/** Act-button audit filters (URL search, spec 04 §12.11.1). */ +export interface ActionFilters { + readonly channel?: string | undefined; + readonly outcome?: readonly string[] | undefined; + readonly notification?: string | undefined; +} + +/** `GET /channels/actions` query for the filters (without cursor). */ +export function actionsQuery(filters: ActionFilters, limit: number) { + return { + limit, + ...(filters.channel !== undefined && { channel_id: filters.channel }), + ...(filters.notification !== undefined && { notification_id: filters.notification }), + ...(filters.outcome !== undefined && + filters.outcome.length > 0 && { + outcome: [...filters.outcome] as NotificationActionOutcome[], + }), + }; +} + +/** One page of the act-button audit (newest first, keyset cursor). */ +export function useActionAudit(filters: ActionFilters, page: number, limit: number) { + const api = useApi(); + const pager = useCursorPager(); + const query = actionsQuery(filters, limit); + const filterKey = JSON.stringify(stableParams(query)); + return useQuery({ + queryKey: keys.channels.actions({ ...query, page }), + queryFn: () => + pager.resolve(filterKey, page, (cursor) => + api.listChannelActions({ + query: { ...query, ...(cursor !== undefined && { cursor }) }, + }), + ), + placeholderData: keepPreviousData, + }); +} + +/** + * Adds a platform user id to a channel's allow-list (the Actions view's "Allow this person"): reads + * the channel, appends the id (deduplicated) and saves the full rules. + */ +export function useAllowPresser() { + const api = useApi(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { readonly channelId: string; readonly userId: string }) => { + const { channel } = await api.getChannel({ params: { channel_id: input.channelId } }); + const current = channel.rules.allow_list ?? []; + if (current.includes(input.userId)) return { channel }; + return api.updateChannel({ + params: { channel_id: input.channelId }, + body: { rules: { ...channel.rules, allow_list: [...current, input.userId] } }, + }); + }, + onSuccess: (result) => patchChannelCaches(queryClient, result.channel), + }); +} diff --git a/packages/dashboard/src/features/notifications/channels/coverage.test.ts b/packages/dashboard/src/features/notifications/channels/coverage.test.ts index bd791cf..221890c 100644 --- a/packages/dashboard/src/features/notifications/channels/coverage.test.ts +++ b/packages/dashboard/src/features/notifications/channels/coverage.test.ts @@ -20,6 +20,11 @@ const OPERATIONS = [ 'resumeChannel', 'testChannel', 'getPublicUrlStatus', + 'getDiscordBot', + 'listDiscordChannels', + 'startDiscordConnect', + 'getDiscordConnect', + 'listChannelActions', ] as const; function sources(dir: string): string[] { diff --git a/packages/dashboard/src/features/notifications/channels/model.test.ts b/packages/dashboard/src/features/notifications/channels/model.test.ts index f2bce0f..32b843f 100644 --- a/packages/dashboard/src/features/notifications/channels/model.test.ts +++ b/packages/dashboard/src/features/notifications/channels/model.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from 'bun:test'; import { TELEGRAM_TTL_MAX_MS } from '@browserhive/contracts/notifications'; import { applyPreset, + channelWhere, cleanRules, draftForKind, + draftForMode, draftProblems, draftToInput, draftToPatch, @@ -14,6 +16,7 @@ import { isPublicNtfy, ntfyLinks, presetOf, + randomReplyTopic, randomTopic, rulesSummary, stepProblems, @@ -140,3 +143,80 @@ describe('snippets and links', () => { expect(isPrivateUrl('not a url')).toBe(false); }); }); + +describe('act buttons and Discord modes', () => { + const discord = draftForKind(EMPTY_DRAFT, 'discord', []); + + it("names only the current mode's variable and swaps it with the mode", () => { + expect(discord.mode).toBe('webhook'); + expect(discord.secretRefs).toEqual({ webhook: 'BH_DISCORD_WEBHOOK' }); + const bot = draftForMode({ ...discord, rules: { ...discord.rules, act_buttons: true } }, 'bot'); + expect(bot.secretRefs).toEqual({ token: 'BH_DISCORD_BOT_TOKEN' }); + const withTarget = { + ...bot, + target: { channel_id: '1', channel_name: 'alerts', guild_id: '2', guild_name: 'Home' }, + }; + const back = draftForMode(withTarget, 'webhook'); + expect(back.secretRefs).toEqual({ webhook: 'BH_DISCORD_WEBHOOK' }); + expect(back.target).toEqual({}); + // Webhook messages cannot carry act buttons, so the switch goes off; the rest is kept. + expect(back.rules.act_buttons).toBeUndefined(); + expect(back.rules.categories).toEqual(discord.rules.categories); + }); + + it('checks act buttons and the allow-list in the rules step', () => { + const webhook = { ...discord, rules: { act_buttons: true, allow_list: ['12', 'me'] } }; + expect(stepProblems(webhook, 'rules').map((p) => p.field)).toEqual([ + 'rules.act_buttons', + 'rules.allow_list', + ]); + const ntfy = draftForKind(EMPTY_DRAFT, 'ntfy', []); + expect( + stepProblems({ ...ntfy, rules: { act_buttons: true } }, 'rules').map((p) => p.field), + ).toEqual(['rules.act_buttons']); + expect( + stepProblems( + { + ...ntfy, + target: { ...ntfy.target, reply_topic: 'bh-reply-x' }, + rules: { act_buttons: true }, + }, + 'rules', + ), + ).toEqual([]); + }); + + it('asks for the reply topic variable in Connect, not in Credentials', () => { + const ntfy = draftForKind(EMPTY_DRAFT, 'ntfy', []); + const draft = { ...ntfy, secretRefs: { ...ntfy.secretRefs, reply_topic: 'bad name' } }; + expect(stepProblems(draft, 'credentials')).toEqual([]); + expect(stepProblems(draft, 'connect').map((p) => p.field)).toContain('secret_refs.reply_topic'); + }); + + it('summarises act buttons and draws hard-to-guess reply topics', () => { + expect(rulesSummary({ act_buttons: true })).toContain('answer from the chat'); + expect(randomReplyTopic(() => 0.5)).toMatch(/^bh-reply-[a-z0-9]{12}$/); + }); + + it('says where a channel sends', () => { + const base = { kind: 'discord', mode: 'bot', target_hint: 'bot from $?' } as const; + expect( + channelWhere({ + ...base, + target: { channel_id: '1', channel_name: 'alerts', guild_name: 'Home' }, + }), + ).toBe('#alerts in Home'); + expect(channelWhere({ ...base, target: { channel_id: '1' } })).toBe('#1'); + expect( + channelWhere({ + kind: 'discord', + mode: 'webhook', + target: {}, + target_hint: 'webhook from $BH_DISCORD_WEBHOOK', + }), + ).toBe('from $BH_DISCORD_WEBHOOK'); + expect(channelWhere({ kind: 'ntfy', mode: null, target: {}, target_hint: 'ntfy.sh/x' })).toBe( + 'ntfy.sh/x', + ); + }); +}); diff --git a/packages/dashboard/src/features/notifications/channels/model.ts b/packages/dashboard/src/features/notifications/channels/model.ts index ee08dd3..12f8e02 100644 --- a/packages/dashboard/src/features/notifications/channels/model.ts +++ b/packages/dashboard/src/features/notifications/channels/model.ts @@ -7,8 +7,10 @@ import { CHANNEL_PRESETS, type ChannelConfigProblem, checkChannelConfig, + checkChannelRules, type NotificationChannelRules, NTFY_DEFAULT_SERVER, + type SecretParamSpec, TELEGRAM_TTL_MAX_MS, } from '@browserhive/contracts/notifications'; import { readStorage, removeStorage, writeStorage } from '@/lib/storage.ts'; @@ -129,6 +131,7 @@ export function rulesSummary(rules: NotificationChannelRules): string { parts.push(rules.mask_images === true ? 'masked screenshots' : 'screenshots'); const ttls = Object.values(rules.ttl_ms ?? {}).filter((v): v is number => typeof v === 'number'); if (ttls.length > 0) parts.push(`self-destruct ${formatTtl(Math.min(...ttls))}`); + if (rules.act_buttons === true) parts.push('answer from the chat'); return parts.join(' · '); } @@ -201,6 +204,11 @@ export interface ChannelDraft { readonly target: Readonly>; readonly secretRefs: Readonly>; readonly rules: NotificationChannelRules; + /** + * Names of the people on the allow-list, by platform user id (who connected the chat in the + * setup). Kept in the draft only: the API stores ids. + */ + readonly people?: Readonly>; } /** A blank draft. */ @@ -233,6 +241,7 @@ export function readDraft(): ChannelDraft | null { target: { ...(d.target ?? {}) }, secretRefs: { ...(d.secretRefs ?? {}) }, rules: { ...(d.rules ?? {}) }, + people: { ...(d.people ?? {}) }, }; } catch { return null; @@ -252,8 +261,8 @@ export function clearDraft(): void { const TOPIC_ALPHABET = 'abcdefghijkmnpqrstuvwxyz23456789'; /** A hard-to-guess ntfy topic (`bh-` + 12 random characters): on a public server the topic is the password. */ -export function randomTopic(random: () => number = Math.random): string { - let out = 'bh-'; +export function randomTopic(random: () => number = Math.random, prefix = 'bh-'): string { + let out = prefix; for (let i = 0; i < 12; i++) out += TOPIC_ALPHABET[Math.floor(random() * TOPIC_ALPHABET.length)]; return out; } @@ -275,6 +284,21 @@ export function suggestName(kind: string, taken: readonly string[]): string { return `${base}-${i}`; } +/** Secret parameters a platform uses in `mode` (Discord's are per mode, D-38). */ +export function modeSecrets(kind: AvailableChannelKind, mode: string | null): SecretParamSpec[] { + const spec = CHANNEL_KIND_SPECS[kind]; + const current = mode ?? spec.defaultMode; + return spec.secrets.filter((s) => s.mode === undefined || s.mode === current); +} + +/** Secret parameters the Connect step edits (the topic or URL from a variable, the ntfy reply topic). */ +export const CONNECT_SECRETS: ReadonlySet = new Set([ + 'topic', + 'url', + 'reply_topic', + 'reply_token', +]); + /** The draft after choosing a platform: its required secrets named, defaults filled, a preset. */ export function draftForKind( draft: ChannelDraft, @@ -284,7 +308,9 @@ export function draftForKind( if (draft.kind === kind) return draft; const spec = CHANNEL_KIND_SPECS[kind]; const secretRefs: Record = {}; - for (const s of spec.secrets) if (s.required) secretRefs[s.param] = s.suggestedEnv; + for (const s of modeSecrets(kind, spec.defaultMode)) { + if (s.required) secretRefs[s.param] = s.suggestedEnv; + } const target: Record = {}; if (kind === 'ntfy') { target['server'] = NTFY_DEFAULT_SERVER; @@ -301,6 +327,37 @@ export function draftForKind( }; } +/** + * The draft after switching the Discord mode (D-38): the other mode's variable and settings are + * replaced by this mode's, and the rules are kept (act buttons go off in webhook mode, where no + * press can arrive). + */ +export function draftForMode(draft: ChannelDraft, mode: string): ChannelDraft { + if (draft.kind === null || draft.mode === mode) return { ...draft, mode }; + const spec = CHANNEL_KIND_SPECS[draft.kind]; + const secretRefs: Record = {}; + for (const [param, env] of Object.entries(draft.secretRefs)) { + const owner = spec.secrets.find((s) => s.param === param)?.mode; + if (owner === undefined || owner === mode) secretRefs[param] = env; + } + for (const s of modeSecrets(draft.kind, mode)) { + if (s.required && secretRefs[s.param] === undefined) secretRefs[s.param] = s.suggestedEnv; + } + const target: Record = {}; + for (const [key, value] of Object.entries(draft.target)) { + const owner = spec.target.find((t) => t.key === key)?.mode; + if (owner === undefined || owner === mode) target[key] = value; + } + const { act_buttons: _act, ...rest } = draft.rules; + return { + ...draft, + mode, + secretRefs, + target, + rules: mode === 'bot' ? draft.rules : rest, + }; +} + /** The draft of an existing channel (editing). */ export function draftFromChannel(channel: ChannelView): ChannelDraft { return { @@ -368,6 +425,15 @@ export function draftProblems(draft: ChannelDraft): ChannelConfigProblem[] { target: draft.target, secretRefs: draft.secretRefs, }); + problems.push( + ...checkChannelRules({ + kind: draft.kind, + mode: draft.mode, + target: draft.target, + secretRefs: draft.secretRefs, + rules: draft.rules, + }), + ); if (!NAME_RE.test(draft.name)) { problems.push({ field: 'name', @@ -395,9 +461,18 @@ export function stepProblems(draft: ChannelDraft, step: WizardStep): ChannelConf case 'platform': return all.filter((p) => p.field === 'kind' || p.field === 'mode'); case 'credentials': - return all.filter((p) => p.field.startsWith('secret_refs')); + return all.filter( + (p) => + p.field.startsWith('secret_refs.') && + !CONNECT_SECRETS.has(p.field.slice('secret_refs.'.length)), + ); case 'connect': - return all.filter((p) => p.field.startsWith('target')); + return all.filter( + (p) => + p.field.startsWith('target') || + (p.field.startsWith('secret_refs.') && + CONNECT_SECRETS.has(p.field.slice('secret_refs.'.length))), + ); case 'rules': return all.filter((p) => p.field === 'name' || p.field.startsWith('rules')); case 'preview': @@ -453,3 +528,27 @@ export function ntfyLinks( export function isPublicNtfy(server: string | undefined): boolean { return (server ?? NTFY_DEFAULT_SERVER).replace(/\/+$/, '') === NTFY_DEFAULT_SERVER; } + +/** A hard-to-guess ntfy reply topic (`bh-reply-` + 12 random characters, D-42). */ +export function randomReplyTopic(random: () => number = Math.random): string { + return randomTopic(random, 'bh-reply-'); +} + +/** A numeric platform user id (Telegram, Discord). */ +export const USER_ID_RE = /^\d{1,21}$/; + +/** + * Where a channel sends, for cards and headers: the server's `target_hint`, except for a Discord + * bot ("#browserhive in Home"), whose hint names no webhook variable. + */ +export function channelWhere( + channel: Pick, +): string { + if (channel.kind === 'discord' && channel.mode === 'bot') { + const room = channel.target['channel_name'] ?? channel.target['channel_id'] ?? 'a channel'; + const server = channel.target['guild_name']; + return `#${room}${server !== undefined && server !== '' ? ` in ${server}` : ''}`; + } + if (channel.kind === 'discord') return channel.target_hint.replace(/^webhook /, ''); + return channel.target_hint; +} diff --git a/packages/dashboard/src/features/notifications/channels/platforms.tsx b/packages/dashboard/src/features/notifications/channels/platforms.tsx index 520cdef..0703ae9 100644 --- a/packages/dashboard/src/features/notifications/channels/platforms.tsx +++ b/packages/dashboard/src/features/notifications/channels/platforms.tsx @@ -27,7 +27,7 @@ export const PLATFORMS: readonly PlatformInfo[] = [ icon: 'platformTelegram', tagline: 'Your own bot messages you, a group or a topic.', setup: 'About 2 minutes', - facts: ['Screenshots', 'Updates in place', 'Self-destruct up to 47 h'], + facts: ['Answer from the chat', 'Screenshots', 'Updates in place', 'Self-destruct up to 47 h'], docs: 'channelTelegram', tile: 'bg-platform-telegram-bg text-platform-telegram', }, @@ -35,9 +35,9 @@ export const PLATFORMS: readonly PlatformInfo[] = [ kind: 'discord', label: 'Discord', icon: 'platformDiscord', - tagline: 'A webhook posts into one channel of your server.', - setup: 'About 30 seconds', - facts: ['Screenshots', 'Updates in place', 'Self-destruct'], + tagline: 'A webhook, or a bot that also takes your answers, posts into a channel.', + setup: '30 s webhook · 3 min bot', + facts: ['Answer with a bot', 'Screenshots', 'Updates in place', 'Self-destruct'], docs: 'channelDiscord', tile: 'bg-platform-discord-bg text-platform-discord', }, @@ -47,7 +47,12 @@ export const PLATFORMS: readonly PlatformInfo[] = [ icon: 'platformNtfy', tagline: 'Push notifications through ntfy.sh or your own server.', setup: 'About 1 minute', - facts: ['No account needed', 'Updates in place', 'Self-destruct'], + facts: [ + 'No account needed', + 'Answer from the notification', + 'Updates in place', + 'Self-destruct', + ], docs: 'channelNtfy', tile: 'bg-platform-ntfy-bg text-platform-ntfy', }, diff --git a/packages/dashboard/src/features/notifications/channels/preview/NtfyMock.tsx b/packages/dashboard/src/features/notifications/channels/preview/NtfyMock.tsx index 8cfc218..7f6b08a 100644 --- a/packages/dashboard/src/features/notifications/channels/preview/NtfyMock.tsx +++ b/packages/dashboard/src/features/notifications/channels/preview/NtfyMock.tsx @@ -1,4 +1,4 @@ -/** @module features/notifications/channels/preview/NtfyMock — an Android notification as the ntfy app shows it, drawn from the renderer's publish request: app row with topic, priority, emoji tags before the title, the message, an attached image and the action buttons. Our own CSS; no ntfy assets. */ +/** @module features/notifications/channels/preview/NtfyMock — an Android notification as the ntfy app shows it, drawn from the renderer's publish request: app row with topic, priority, emoji tags before the title, the message, an attached image and the action buttons (`http` ones answer through the reply topic, D-42). Our own CSS; no ntfy assets. */ import type { PlatformRequest } from '@browserhive/contracts/http'; import { ICONS } from '@/lib/icons.ts'; import { cn } from '@/lib/utils.ts'; @@ -30,6 +30,7 @@ export function NtfyMock({ request, at, masked, topic, revised = false }: NtfyMo const time = new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const Bell = ICONS.platformNtfy; const Warn = ICONS.warn; + const Answer = ICONS.answer; const shownTopic = view.topic ?? topic ?? 'topic'; return (
      @@ -77,15 +78,27 @@ export function NtfyMock({ request, at, masked, topic, revised = false }: NtfyMo ) : null} {view.actions.length > 0 ? (
      - {view.actions.map((a) => ( - - {a.label} - - ))} + {view.actions.map((a) => + a.kind === 'http' ? ( + + ) : ( + + {a.label} + + ), + )}
      ) : null}
      diff --git a/packages/dashboard/src/features/notifications/channels/preview/TelegramMock.tsx b/packages/dashboard/src/features/notifications/channels/preview/TelegramMock.tsx index fc555b0..ad22e99 100644 --- a/packages/dashboard/src/features/notifications/channels/preview/TelegramMock.tsx +++ b/packages/dashboard/src/features/notifications/channels/preview/TelegramMock.tsx @@ -1,4 +1,4 @@ -/** @module features/notifications/channels/preview/TelegramMock — a Telegram chat drawn from the renderer's `sendMessage`/`sendPhoto` request: the bot header, the message bubble with the HTML subset (bold, italic, code, links, expandable quotes, spoilers), an optional photo, the silent-send mark and the inline keyboard. Our own CSS; no Telegram assets. */ +/** @module features/notifications/channels/preview/TelegramMock — a Telegram chat drawn from the renderer's request: a Rich Message (`sendRichMessage` / `editMessageText` with `rich_message`: headings, paragraphs, tables, lists, expandable quotes, code, footers and the inline screenshot, D-40) or the classic `sendMessage`/`sendPhoto` HTML subset; the bot header, the silent-send mark and the inline keyboard with callback buttons tinted by style. Our own CSS; no Telegram assets. */ import type { PlatformRequest } from '@browserhive/contracts/http'; import { type ReactNode, useState } from 'react'; import { ICONS } from '@/lib/icons.ts'; @@ -45,13 +45,27 @@ function Spoiler({ children }: { readonly children: ReactNode }) { ); } -function renderNodes(nodes: readonly TgNode[], key: string): ReactNode[] { - return nodes.map((node, i) => renderNode(node, `${key}.${i}`)); +/** What the node renderer needs besides the nodes. */ +interface RenderCtx { + readonly masked: boolean; +} + +function renderNodes(nodes: readonly TgNode[], key: string, ctx: RenderCtx): ReactNode[] { + return nodes.map((node, i) => renderNode(node, `${key}.${i}`, ctx)); } -function renderNode(node: TgNode, k: string): ReactNode { +const HEADING: Readonly> = { + 1: 'text-[1.3rem] font-bold', + 2: 'text-[1.18rem] font-bold', + 3: 'text-[1.05rem] font-semibold', + 4: 'text-[0.95rem] font-semibold', + 5: 'text-[0.9rem] font-semibold', + 6: 'text-[0.9rem] font-semibold', +}; + +function renderNode(node: TgNode, k: string, ctx: RenderCtx): ReactNode { if (node.type === 'text') return {node.text}; - const children = renderNodes(node.children, k); + const children = renderNodes(node.children, k, ctx); switch (node.tag) { case 'b': return ( @@ -128,9 +142,101 @@ function renderNode(node: TgNode, k: string): ReactNode { ); case 'emoji': return {children}; + case 'h': + return ( + + {children} + + ); + case 'p': + return ( + + {children} + + ); + case 'br': + return
      ; + case 'hr': + return
    + {children} +
    + + ); + case 'tr': + return ( + + {children} + + ); + case 'th': + return ( + + {children} + + ); + case 'td': + return ( + + {children} + + ); + case 'ul': + return ( +
      + {children} +
    + ); + case 'ol': + return ( +
      + {children} +
    + ); + case 'li': + return
  • {children}
  • ; + case 'footer': + return ( + + {children} + + ); } } +const KEY_TINT: Readonly> = { + success: 'bg-tg-key-success text-white hover:brightness-110', + danger: 'bg-tg-key-danger text-white hover:brightness-110', + primary: 'bg-tg-key-primary text-white hover:brightness-110', +}; + /** Props. */ export interface TelegramMockProps { readonly request: PlatformRequest; @@ -144,7 +250,8 @@ export interface TelegramMockProps { /** The Telegram chat mock. */ export function TelegramMock({ request, at, masked, botName, chatTitle }: TelegramMockProps) { const view = readTelegram(request); - const nodes = parseTelegramHtml(view.html); + const nodes = parseTelegramHtml(view.html, { rich: view.rich }); + const ctx: RenderCtx = { masked }; const time = new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const Silent = ICONS.notificationsOff; const Arrow = ICONS.arrowUpRight; @@ -179,8 +286,13 @@ export function TelegramMock({ request, at, masked, botName, chatTitle }: Telegr ) : null} {view.photo !== null ? : null} -
    - {renderNodes(nodes, 'm')} +
    + {renderNodes(nodes, 'm', ctx)} {view.edit ? 'edited ' : ''} {view.silent ? : null} @@ -196,7 +308,10 @@ export function TelegramMock({ request, at, masked, botName, chatTitle }: Telegr {b.label} {b.url !== null ? ( diff --git a/packages/dashboard/src/features/notifications/channels/preview/read-request.test.ts b/packages/dashboard/src/features/notifications/channels/preview/read-request.test.ts new file mode 100644 index 0000000..2f4307c --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/preview/read-request.test.ts @@ -0,0 +1,103 @@ +/** @module features/notifications/channels/preview/read-request.test — the request readers for act buttons: a Telegram Rich Message (html from `rich_message`, no separate photo, styled keys), Discord interactive button styles, ntfy `http` actions (D-40, D-41, D-42) */ +import { describe, expect, it } from 'bun:test'; +import type { PlatformRequest } from '@browserhive/contracts/http'; +import { readDiscord, readNtfy, readTelegram } from './read-request.ts'; + +function request(overrides: Partial): PlatformRequest { + return { + method: 'POST', + path: '/sendMessage', + encoding: 'json', + body: {}, + headers: {}, + file: null, + ...overrides, + } as PlatformRequest; +} + +describe('readTelegram', () => { + it('reads a Rich Message with its inline screenshot and styled buttons', () => { + const view = readTelegram( + request({ + path: '/sendRichMessage', + encoding: 'multipart', + file: { name: 'shot.jpg', content_type: 'image/jpeg' }, + body: { + rich_message: JSON.stringify({ + html: '

    Attention

    ', + media: [{ id: 'shot', media: { type: 'photo', media: 'attach://shot' } }], + }), + reply_markup: JSON.stringify({ + inline_keyboard: [ + [ + { text: 'Mark resolved', callback_data: 'bh1:aaaaaaaaaaa', style: 'success' }, + { text: 'Reject', callback_data: 'bh1:bbbbbbbbbbb', style: 'danger' }, + ], + [{ text: 'Open', url: 'https://bh.example.net/a' }], + ], + }), + }, + }), + ); + expect(view.rich).toBe(true); + expect(view.html).toContain('

    Attention

    '); + expect(view.photo).toBeNull(); + expect(view.rows.map((r) => r.map((b) => b.style))).toEqual([['success', 'danger'], ['link']]); + expect(view.rows[0]?.[0]?.url).toBeNull(); + }); + + it('keeps the classic photo message as it was', () => { + const view = readTelegram( + request({ + path: '/sendPhoto', + encoding: 'multipart', + file: { name: 'screenshot.jpg', content_type: 'image/jpeg' }, + body: { caption: 'Hi', parse_mode: 'HTML' }, + }), + ); + expect(view.rich).toBe(false); + expect(view.photo).toEqual({ name: 'screenshot.jpg' }); + }); +}); + +describe('readDiscord', () => { + it('maps interactive button styles', () => { + const view = readDiscord( + request({ + path: '/channels/1/messages', + body: { + components: [ + { + type: 1, + components: [ + { type: 2, style: 3, label: 'Approve', custom_id: 'bh1:a' }, + { type: 2, style: 4, label: 'Reject', custom_id: 'bh1:b' }, + { type: 2, style: 5, label: 'Open', url: 'https://bh.example.net/a' }, + ], + }, + ], + }, + }), + ); + expect(view.rows[0]?.map((b) => b.style)).toEqual(['success', 'danger', 'link']); + }); +}); + +describe('readNtfy', () => { + it('keeps http actions apart from view actions', () => { + const view = readNtfy( + request({ + path: '/', + body: { + topic: 'bh-alerts', + message: 'x', + actions: [ + { action: 'http', label: 'Mark resolved', url: 'https://ntfy.sh/bh-reply-x' }, + { action: 'view', label: 'Open', url: 'https://bh.example.net/a' }, + ], + }, + }), + ); + expect(view.actions.map((a) => a.kind)).toEqual(['http', 'view']); + }); +}); diff --git a/packages/dashboard/src/features/notifications/channels/preview/read-request.ts b/packages/dashboard/src/features/notifications/channels/preview/read-request.ts index b1adb50..7d44173 100644 --- a/packages/dashboard/src/features/notifications/channels/preview/read-request.ts +++ b/packages/dashboard/src/features/notifications/channels/preview/read-request.ts @@ -42,6 +42,8 @@ export interface MockButton { /** What the Telegram mock draws. */ export interface TelegramView { readonly html: string; + /** A Rich Message (`rich_message.html`, D-40) rather than classic `parse_mode: HTML` text. */ + readonly rich: boolean; readonly photo: { readonly name: string } | null; readonly rows: readonly (readonly MockButton[])[]; readonly silent: boolean; @@ -53,7 +55,9 @@ export interface TelegramView { /** Reads a Telegram `sendMessage` / `sendPhoto` / edit request. */ export function readTelegram(request: PlatformRequest): TelegramView { const body = request.body; - const text = str(body['text']) ?? str(body['caption']) ?? ''; + const richMessage = parsed(body['rich_message']); + const richHtml = isRecord(richMessage) ? str(richMessage['html']) : null; + const text = richHtml ?? str(body['text']) ?? str(body['caption']) ?? ''; const markup = parsed(body['reply_markup']); const keyboard = isRecord(markup) ? arr(markup['inline_keyboard']) : []; const rows = keyboard.map((row) => @@ -61,14 +65,24 @@ export function readTelegram(request: PlatformRequest): TelegramView { if (!isRecord(b)) return []; const label = str(b['text']) ?? ''; const url = str(b['url']); - return [{ label, url, style: url === null ? 'secondary' : 'link' }]; + const tint = str(b['style']); + const style: MockButton['style'] = + tint === 'success' || tint === 'danger' || tint === 'primary' + ? tint + : url === null + ? 'secondary' + : 'link'; + return [{ label, url, style }]; }), ); const method = request.path.replace(/^\//, ''); return { html: text, + rich: richHtml !== null, + // A Rich Message places its screenshot inline (``). photo: - request.file !== null || method === 'sendPhoto' || method === 'editMessageCaption' + richHtml === null && + (request.file !== null || method === 'sendPhoto' || method === 'editMessageCaption') ? { name: request.file?.name ?? 'screenshot.jpg' } : null, rows: rows.filter((r) => r.length > 0), @@ -172,6 +186,7 @@ export function readDiscord(request: PlatformRequest): DiscordView { export interface NtfyAction { readonly label: string; readonly url: string | null; + /** `view`, `http` (an answer posted to the reply topic, D-42), `broadcast`, `copy`. */ readonly kind: string; } diff --git a/packages/dashboard/src/features/notifications/channels/preview/telegram-html.test.ts b/packages/dashboard/src/features/notifications/channels/preview/telegram-html.test.ts index 3ecf598..6e4f042 100644 --- a/packages/dashboard/src/features/notifications/channels/preview/telegram-html.test.ts +++ b/packages/dashboard/src/features/notifications/channels/preview/telegram-html.test.ts @@ -77,3 +77,35 @@ describe('discord timestamps', () => { ]); }); }); + +describe('parseTelegramHtml (rich)', () => { + it('parses the Rich Message block tags only when asked', () => { + const html = + '

    Attention

    Page x


    Modelive
    BrowserHive
    '; + const rich = parseTelegramHtml(html, { rich: true }); + expect(rich.map((n) => (n.type === 'el' ? n.tag : 'text'))).toEqual([ + 'h', + 'p', + 'hr', + 'img', + 'table', + 'footer', + ]); + const heading = rich[0]; + expect(heading?.type === 'el' && heading.level).toBe(3); + const img = rich[3]; + expect(img?.type === 'el' && img.src).toBe('tg://photo?id=shot'); + const table = rich[4]; + expect(table?.type === 'el' && table.bordered && table.compact).toBe(true); + // Classic HTML mode does not know these tags: they stay visible as text. + const classic = parseTelegramHtml('

    Attention

    '); + expect(classic.every((n) => n.type === 'text')).toBe(true); + }); + + it('keeps a script or event handler as text', () => { + const nodes = parseTelegramHtml('', { + rich: true, + }); + expect(nodes.some((n) => n.type === 'el' && n.tag === ('script' as never))).toBe(false); + }); +}); diff --git a/packages/dashboard/src/features/notifications/channels/preview/telegram-html.ts b/packages/dashboard/src/features/notifications/channels/preview/telegram-html.ts index acf7fa6..7c9983a 100644 --- a/packages/dashboard/src/features/notifications/channels/preview/telegram-html.ts +++ b/packages/dashboard/src/features/notifications/channels/preview/telegram-html.ts @@ -1,4 +1,4 @@ -/** @module features/notifications/channels/preview/telegram-html — parses the HTML subset of Telegram's `parse_mode: HTML` into a small tree the mock renders as React nodes; never `innerHTML`. Unknown tags and malformed markup stay visible as text, as Telegram would refuse them. */ +/** @module features/notifications/channels/preview/telegram-html — parses the HTML subset of Telegram's `parse_mode: HTML`, and (with `rich`) the block tags of Rich Messages (headings, paragraphs, tables, lists, footers, rules, media), into a small tree the mock renders as React nodes; never `innerHTML`. Unknown tags and malformed markup stay visible as text, as Telegram would refuse them. */ /** A node of the parsed message. */ export type TgNode = @@ -11,6 +11,13 @@ export type TgNode = readonly language?: string; /** ``: the moment, shown in the reader's time zone. */ readonly unix?: number; + /** `

    `…`

    `. */ + readonly level?: number; + /** `` (rich): the media reference (`tg://photo?id=shot`). */ + readonly src?: string; + /** `` / `compact` (rich). */ + readonly bordered?: boolean; + readonly compact?: boolean; readonly children: readonly TgNode[]; }; @@ -26,7 +33,23 @@ export type TgTag = | 'blockquote' | 'spoiler' | 'time' - | 'emoji'; + | 'emoji' + // Rich Message blocks (D-40) + | 'h' + | 'p' + | 'br' + | 'hr' + | 'img' + | 'table' + | 'tr' + | 'td' + | 'th' + | 'ul' + | 'ol' + | 'li' + | 'footer' + | 'figure' + | 'figcaption'; const ALIASES: Readonly> = { b: 'b', @@ -47,6 +70,33 @@ const ALIASES: Readonly> = { 'tg-emoji': 'emoji', }; +/** Block tags Rich Messages add (Bot API 10.1). */ +const RICH: Readonly> = { + h1: 'h', + h2: 'h', + h3: 'h', + h4: 'h', + h5: 'h', + h6: 'h', + p: 'p', + br: 'br', + hr: 'hr', + img: 'img', + table: 'table', + tr: 'tr', + td: 'td', + th: 'th', + ul: 'ul', + ol: 'ol', + li: 'li', + footer: 'footer', + figure: 'figure', + figcaption: 'figcaption', +}; + +/** Tags without content or a closing tag. */ +const VOID: ReadonlySet = new Set(['br', 'hr', 'img']); + const NAMED: Readonly> = { lt: '<', gt: '>', @@ -111,6 +161,18 @@ function close(frame: Frame): TgNode { const unix = Number(frame.attrs['unix']); return Number.isFinite(unix) ? { ...base, unix } : base; } + if (frame.tag === 'h') return { ...base, level: Number(frame.name.slice(1)) || 3 }; + if (frame.tag === 'img') { + const src = frame.attrs['src']; + return { ...base, ...(typeof src === 'string' && { src }) }; + } + if (frame.tag === 'table') { + return { + ...base, + bordered: frame.attrs['bordered'] !== undefined, + compact: frame.attrs['compact'] !== undefined, + }; + } if (frame.tag === 'code') { const cls = frame.attrs['class']; if (typeof cls === 'string' && cls.startsWith('language-')) { @@ -124,7 +186,11 @@ function close(frame: Frame): TgNode { * Parses Telegram HTML into nodes. `` is a spoiler; tags outside the * subset, stray closing tags and unclosed tags are kept as literal text. */ -export function parseTelegramHtml(html: string): readonly TgNode[] { +export function parseTelegramHtml( + html: string, + options: { readonly rich?: boolean } = {}, +): readonly TgNode[] { + const rich = options.rich === true; const root: Frame = { tag: null, name: '#root', attrs: {}, children: [] }; const stack: Frame[] = [root]; const top = () => stack[stack.length - 1] ?? root; @@ -136,7 +202,7 @@ export function parseTelegramHtml(html: string): readonly TgNode[] { const closing = m[1] === '/'; const name = (m[2] ?? '').toLowerCase(); const attrs = parseAttrs(m[3] ?? ''); - let tag: TgTag | undefined = ALIASES[name]; + let tag: TgTag | undefined = ALIASES[name] ?? (rich ? RICH[name] : undefined); if (name === 'span' && attrs['class'] === 'tg-spoiler') tag = 'spoiler'; if (name === 'span' && closing) { const open = [...stack].reverse().find((f) => f.name === 'span'); @@ -146,6 +212,11 @@ export function parseTelegramHtml(html: string): readonly TgNode[] { pushText(top(), m[0]); continue; } + if (!closing && VOID.has(tag)) { + top().children.push(close({ tag, name, attrs, children: [] })); + continue; + } + if (closing && VOID.has(tag)) continue; if (!closing) { stack.push({ tag, name, attrs, children: [] }); continue; diff --git a/packages/dashboard/src/features/notifications/channels/search.ts b/packages/dashboard/src/features/notifications/channels/search.ts index 03d82a4..8bc4051 100644 --- a/packages/dashboard/src/features/notifications/channels/search.ts +++ b/packages/dashboard/src/features/notifications/channels/search.ts @@ -1,5 +1,6 @@ -/** @module features/notifications/channels/search — URL search of the channel wizard (`step`, `kind`) and the delivery log (`channel`, `status`, `op`, `kind`, `notification`, `seq`, `page`, `ps`) (spec 04 §1, §12.11.1) */ +/** @module features/notifications/channels/search — URL search of the channel wizard (`step`, `kind`), the delivery log (`channel`, `status`, `op`, `kind`, `notification`, `seq`, `page`, `ps`) and the act-button audit (`channel`, `outcome`, `page`, `ps`) (spec 04 §1, §12.11.1) */ import { + NotificationActionOutcome, NotificationDeliveryOp, NotificationDeliveryStatus, NotificationKind, @@ -35,3 +36,15 @@ export const deliveryLogSearch = z.object({ export type DeliveryLogSearch = z.infer; /** Defaults omitted from the URL. */ export const DELIVERY_LOG_DEFAULTS = { ...TABLE_SEARCH_DEFAULTS } as const; + +/** Act-button audit search (`/notifications/actions`). */ +export const actionsSearch = z.object({ + channel: z.string().max(80).optional().catch(undefined), + outcome: csvParam(NotificationActionOutcome), + page: pageParam, + ps: pageSizeParam, +}); +/** Act-button audit search. */ +export type ActionsSearch = z.infer; +/** Defaults omitted from the URL. */ +export const ACTIONS_DEFAULTS = { ...TABLE_SEARCH_DEFAULTS } as const; diff --git a/packages/dashboard/src/features/notifications/channels/wizard/ActButtonsSection.test.tsx b/packages/dashboard/src/features/notifications/channels/wizard/ActButtonsSection.test.tsx new file mode 100644 index 0000000..668b361 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/wizard/ActButtonsSection.test.tsx @@ -0,0 +1,110 @@ +/** @module features/notifications/channels/wizard/ActButtonsSection.test — "Answer from the chat": the switch, disabled with the reason and a way out where presses cannot arrive (Discord webhook, ntfy without a reply topic), the "Allowed people" list (names from the connect step, ids otherwise; add with validation, remove), the ntfy note, axe clean */ +import { describe, expect, it } from 'bun:test'; +import type { NotificationChannelRules } from '@browserhive/contracts/notifications'; +import { useState } from 'react'; +import { expectNoA11yViolations } from '../../../../../test/helpers/axe.ts'; +import { fireEvent, render, screen } from '../../../../../test/helpers/render.tsx'; +import { type ChannelDraft, draftForKind, draftForMode, EMPTY_DRAFT } from '../model.ts'; +import { ActButtonsSection, actButtonsBlocker } from './ActButtonsSection.tsx'; + +const TELEGRAM: ChannelDraft = { + ...draftForKind(EMPTY_DRAFT, 'telegram', []), + target: { chat_id: '42' }, + rules: { act_buttons: true, allow_list: ['1111'] }, + people: { '1111': 'Amir G' }, +}; + +function Harness({ + initial, + onStep, +}: { + readonly initial: ChannelDraft; + readonly onStep?: (step: 'platform' | 'connect') => void; +}) { + const [draft, setDraft] = useState(initial); + return ( + <> + setDraft((d) => ({ ...d, rules }))} + errors={{}} + readOnly={false} + connection={null} + onStep={onStep} + /> + {JSON.stringify(draft.rules)} + + ); +} + +const rules = () => JSON.parse(screen.getByTestId('rules').textContent ?? '{}'); + +describe('ActButtonsSection', () => { + it('knows where presses cannot arrive', () => { + const discord = draftForKind(EMPTY_DRAFT, 'discord', []); + expect(actButtonsBlocker(discord)).toContain('bot mode'); + expect(actButtonsBlocker(draftForMode(discord, 'bot'))).toBeNull(); + expect(actButtonsBlocker(draftForKind(EMPTY_DRAFT, 'ntfy', []))).toContain('reply topic'); + expect(actButtonsBlocker(TELEGRAM)).toBeNull(); + }); + + it('switches act buttons and edits the allowed people', async () => { + const view = render(); + const toggle = screen.getByRole('switch', { name: 'Answer from the chat' }); + expect(screen.queryByText('Allowed people')).toBeNull(); + fireEvent.click(toggle); + expect(rules().act_buttons).toBe(true); + expect(screen.getByText('Allowed people')).toBeDefined(); + expect(screen.getByText('Nobody can answer yet')).toBeDefined(); + await expectNoA11yViolations(view.container); + }); + + it('adds numeric ids once and removes people', () => { + render(); + expect(screen.getByText('Amir G')).toBeDefined(); + const input = screen.getByLabelText('Add a Telegram user id'); + fireEvent.change(input, { target: { value: 'sam' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + expect(screen.getByRole('alert').textContent).toContain('is a number'); + fireEvent.change(input, { target: { value: '1111' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + expect(screen.getByRole('alert').textContent).toContain('already on the list'); + fireEvent.change(input, { target: { value: '2222' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(rules().allow_list).toEqual(['1111', '2222']); + expect(screen.getByText('Telegram user id')).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Remove Amir G from the allowed people' })); + expect(rules().allow_list).toEqual(['2222']); + }); + + it('explains a Discord webhook and leads to bot mode', () => { + const steps: string[] = []; + render( + steps.push(step)} + />, + ); + const toggle = screen.getByRole('switch', { name: 'Answer from the chat' }); + expect(toggle.hasAttribute('data-disabled')).toBe(true); + fireEvent.click(toggle); + expect(rules().act_buttons).toBeUndefined(); + fireEvent.click(screen.getByRole('button', { name: 'Choose bot mode' })); + expect(steps).toEqual(['platform']); + }); + + it('tells that ntfy has no allow-list', () => { + const ntfy = draftForKind(EMPTY_DRAFT, 'ntfy', []); + render( + , + ); + expect(screen.getByText(/ntfy has no accounts/)).toBeDefined(); + expect(screen.queryByText('Allowed people')).toBeNull(); + }); +}); diff --git a/packages/dashboard/src/features/notifications/channels/wizard/ActButtonsSection.tsx b/packages/dashboard/src/features/notifications/channels/wizard/ActButtonsSection.tsx new file mode 100644 index 0000000..168623f --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/wizard/ActButtonsSection.tsx @@ -0,0 +1,335 @@ +/** @module features/notifications/channels/wizard/ActButtonsSection — "Answer from the chat" in the rules step (D-41): the act-button switch (off by default; disabled with the reason where the setup cannot receive presses), and for Telegram and Discord the allow-list editor (the person who connected the chat first, more numeric ids addable and removable); ntfy and the webhook explain who can answer instead */ +import type { ChannelConnection } from '@browserhive/contracts/http'; +import { + hasPresserIdentity, + type NotificationChannelRules, + supportsActButtons, +} from '@browserhive/contracts/notifications'; +import { useId, useState } from 'react'; +import { Callout } from '@/components/shared/Callout.tsx'; +import { StatusDot } from '@/components/shared/StatusBadge.tsx'; +import { Button } from '@/components/ui/button.tsx'; +import { Input } from '@/components/ui/input.tsx'; +import { Switch } from '@/components/ui/switch.tsx'; +import { ICONS } from '@/lib/icons.ts'; +import { LISTENER_STATE } from '@/lib/status-registry.ts'; +import { cn } from '@/lib/utils.ts'; +import { type ChannelDraft, USER_ID_RE } from '../model.ts'; + +const PLATFORM_NAME: Readonly> = { + telegram: 'Telegram', + discord: 'Discord', + ntfy: 'ntfy', + webhook: 'your receiver', +}; + +/** Where the answer happens, for the description. */ +const WHERE_ANSWERED: Readonly> = { + telegram: 'right in Telegram', + discord: 'right in Discord', + ntfy: 'right from the notification', + webhook: 'through your own receiver', +}; + +/** Why act buttons cannot be switched on for this setup, or `null`. */ +export function actButtonsBlocker(draft: ChannelDraft): string | null { + if (draft.kind === null) return null; + const supported = supportsActButtons({ + kind: draft.kind, + mode: draft.mode, + target: draft.target, + secretRefs: draft.secretRefs, + }); + if (supported) return null; + if (draft.kind === 'discord') { + return 'A Discord webhook can only carry links. Switch this channel to bot mode to answer from Discord.'; + } + if (draft.kind === 'ntfy') { + return 'ntfy needs a reply topic for the buttons to post to. Add one in the Connect step.'; + } + return 'This platform cannot receive button presses.'; +} + +function initials(name: string): string { + const parts = name.replace(/^@/, '').split(/\s+/).filter(Boolean); + const letters = + parts.length > 1 ? `${parts[0]?.[0] ?? ''}${parts[1]?.[0] ?? ''}` : name.slice(0, 2); + return letters.toUpperCase(); +} + +/** Props. */ +export interface ActButtonsSectionProps { + readonly draft: ChannelDraft; + readonly onRules: (rules: NotificationChannelRules) => void; + readonly errors: Readonly>; + readonly readOnly: boolean; + /** The saved channel's press listener, when editing. */ + readonly connection: ChannelConnection | null; + /** Opens another wizard step (the Platform step for Discord's mode, Connect for ntfy). */ + readonly onStep?: ((step: 'platform' | 'connect') => void) | undefined; +} + +/** The act-button settings. */ +export function ActButtonsSection({ + draft, + onRules, + errors, + readOnly, + connection, + onStep, +}: ActButtonsSectionProps) { + const rules = draft.rules; + const on = rules.act_buttons === true; + const blocker = actButtonsBlocker(draft); + const switchId = useId(); + const inputId = useId(); + const [candidate, setCandidate] = useState(''); + const [inputError, setInputError] = useState(null); + const allow = rules.allow_list ?? []; + const people = draft.people ?? {}; + const kind = draft.kind ?? 'webhook'; + const platform = PLATFORM_NAME[kind] ?? kind; + const identity = hasPresserIdentity(kind); + const Answer = ICONS.answer; + const Users = ICONS.allowList; + const Remove = ICONS.close; + const Add = ICONS.plus; + const Warn = ICONS.warn; + const User = ICONS.user; + + const setAllow = (next: readonly string[]) => + onRules({ ...rules, allow_list: next.length === 0 ? undefined : [...next] }); + const add = () => { + const id = candidate.trim(); + if (!USER_ID_RE.test(id)) { + setInputError(`A ${platform} user id is a number, like 123456789.`); + return; + } + if (allow.includes(id)) { + setInputError('That id is already on the list.'); + return; + } + if (allow.length >= 32) { + setInputError('The list holds 32 people at most.'); + return; + } + setAllow([...allow, id]); + setCandidate(''); + setInputError(null); + }; + + return ( +
    +
    + +
    + +

    + {`Approve, Reject and Mark resolved work ${WHERE_ANSWERED[kind] ?? `in ${platform}`}; everything else still opens BrowserHive.`} + {kind === 'webhook' + ? ' Off by default.' + : ` Each button works once, for 24 hours, and only while the request waits.${on ? '' : ' Off by default.'}`} +

    + {blocker !== null ? ( +

    +

    + ) : null} + {connection !== null && on ? ( +

    + Listening for presses: + + {connection.detail !== null ? ( + {connection.detail} + ) : null} +

    + ) : null} +
    + + onRules({ ...rules, act_buttons: checked ? true : undefined }) + } + className="mt-1" + /> +
    + {errors['rules.act_buttons'] !== undefined ? ( +

    + {errors['rules.act_buttons']} +

    + ) : null} + + {on && blocker === null && identity ? ( +
    +
    +
    + {allow.length > 0 ? ( +
      + {allow.map((id) => { + const name = people[id]; + return ( +
    • + + + {name !== undefined ? ( + <> + {name} + + {id} + + + ) : ( + <> + {id} + + {platform} user id + + + )} + + {name !== undefined ? ( + + connected in setup + + ) : null} + {!readOnly ? ( + + ) : null} +
    • + ); + })} +
    + ) : ( + + Presses from people who are not on this list are refused.{' '} + {kind === 'telegram' + ? 'Connect the chat in the Connect step to add yourself, or add an id below.' + : 'Link your Discord account in the Connect step, or add an id below.'} + + )} + {!readOnly ? ( +
    + +
    + { + setCandidate(e.target.value.trim()); + setInputError(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + add(); + } + }} + /> + +
    + {inputError !== null || errors['rules.allow_list'] !== undefined ? ( +

    + {inputError ?? errors['rules.allow_list']} +

    + ) : ( +

    + {kind === 'telegram' + ? 'When someone not on the list presses a button, the bot tells them their id. Add it here to let them answer.' + : 'In Discord, turn on Developer Mode (User Settings → Advanced), then right-click a member and choose Copy User ID.'} +

    + )} +
    + ) : null} +
    + ) : null} + + {on && blocker === null && kind === 'ntfy' ? ( +
    + ntfy has no accounts, so there is no allow-list: whoever can read your notification topic + can press its buttons. Keep that topic private (a long random name, or a protected topic + on your own server). +
    + ) : null} + {on && kind === 'webhook' ? ( +
    + The Approve and Reject actions travel in the payload as they are. Your receiver answers + through the BrowserHive API with its own token (for example{' '} + POST /api/v1/attention/<id>/resolve). +
    + ) : null} +
    + ); +} diff --git a/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.test.tsx b/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.test.tsx index 9098e8e..59c5685 100644 --- a/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.test.tsx +++ b/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.test.tsx @@ -5,6 +5,7 @@ import { CAPTURED } from '../../../../../test/fixtures/channels.ts'; import { expectNoA11yViolations } from '../../../../../test/helpers/axe.ts'; import { type RecordedRequest, renderPage } from '../../../../../test/helpers/page-harness.tsx'; import { act, fireEvent } from '../../../../../test/helpers/render.tsx'; +import { pickOption } from '../../../../../test/helpers/select.ts'; import { DRAFT_KEY, readDraft } from '../model.ts'; import { wizardSearch } from '../search.ts'; import { NewChannelPage } from './ChannelWizardPage.tsx'; @@ -21,8 +22,16 @@ async function until(check: () => boolean, timeoutMs = 3000): Promise { } const text = () => document.body.textContent ?? ''; +/** The id of the Channel select (its label's `for`). */ +const channelTrigger = () => + [...document.querySelectorAll('label')] + .find((l) => l.textContent === 'Channel') + ?.getAttribute('for') ?? ''; -function mount(url: string, options: { envSet?: () => boolean } = {}) { +function mount( + url: string, + options: { envSet?: () => boolean; routes?: Record } = {}, +) { let polls = 0; const created: unknown[] = []; const view = renderPage({ @@ -66,6 +75,7 @@ function mount(url: string, options: { envSet?: () => boolean } = {}) { }, }; }, + ...options.routes, }, }); return { ...view, created, polls: () => polls }; @@ -87,6 +97,68 @@ const button = (label: RegExp) => { afterEach(() => localStorage.removeItem(DRAFT_KEY)); describe('channel wizard', () => { + it('connects a Discord bot: invite, server and channel, "This is me"', async () => { + const GUILD = '111111111111111111'; + const CHANNEL = '222222222222222222'; + localStorage.setItem( + DRAFT_KEY, + JSON.stringify({ + v: 1, + kind: 'discord', + mode: 'bot', + name: 'ops-bot', + target: {}, + secretRefs: { token: 'BH_DISCORD_BOT_TOKEN' }, + rules: { categories: ['needs-you'], act_buttons: true }, + }), + ); + let polled = 0; + const view = mount('/notifications/channels/new?step=connect', { + routes: { + 'POST /channels/discord/bot': { + application_id: '333333333333333333', + bot_id: '333333333333333333', + bot_username: 'BrowserHive Bot', + invite_url: + 'https://discord.com/oauth2/authorize?client_id=333333333333333333&scope=bot&permissions=52224', + guilds: [{ id: GUILD, name: 'Home' }], + }, + 'POST /channels/discord/channels': { + channels: [{ id: CHANNEL, name: 'alerts', type: 'text', category: null }], + }, + 'POST /channels/discord/connect': { connect_id: 'dx-demo-1234', expires_at: EXPIRES }, + 'GET /channels/discord/connect/dx-demo-1234': () => { + polled += 1; + return { + status: 'connected', + user: { id: '444444444444444444', name: 'Amir' }, + error: null, + expires_at: EXPIRES, + }; + }, + }, + }); + await until(() => text().includes('BrowserHive Bot')); + const invite = [...document.querySelectorAll('a')].find((a) => + a.textContent?.includes('Invite the bot'), + ); + expect(invite?.getAttribute('href')).toContain('permissions=52224'); + // The only server is picked; then the channel. + await until(() => readDraft()?.target['guild_id'] === GUILD); + expect(readDraft()?.target['guild_name']).toBe('Home'); + await until(() => view.requests.some((r) => r.path.endsWith('/discord/channels'))); + const trigger = () => document.getElementById(channelTrigger()) as HTMLElement; + await until(() => trigger() !== null && !trigger().hasAttribute('data-disabled')); + await pickOption(trigger(), '#alerts'); + await until(() => readDraft()?.target['channel_id'] === CHANNEL); + expect(readDraft()?.target['channel_name']).toBe('alerts'); + await click(button(/Send the link message/)); + await until(() => text().includes('Connected as Amir')); + expect(polled).toBeGreaterThan(0); + expect(readDraft()?.rules.allow_list?.[0]).toBe('444444444444444444'); + await expectNoA11yViolations(view.container); + }, 30_000); + it('walks Telegram: platform, credentials, connect, rules', async () => { let set = false; const view = mount('/notifications/channels/new', { envSet: () => set }); diff --git a/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.tsx b/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.tsx index 50757f5..613803d 100644 --- a/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.tsx +++ b/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.tsx @@ -28,9 +28,11 @@ import { } from '../api.ts'; import { type ChannelDraft, + channelWhere, clearDraft, draftEnvNames, draftForKind, + draftForMode, draftFromChannel, draftProblems, draftToInput, @@ -229,7 +231,7 @@ function Wizard({ draft={draft} locked={editing || readOnly} onKind={(kind: AvailableChannelKind) => setDraft((d) => draftForKind(d, kind, taken))} - onMode={(mode) => setDraft((d) => ({ ...d, mode }))} + onMode={(mode) => setDraft((d) => draftForMode(d, mode))} /> ); case 'credentials': @@ -254,10 +256,21 @@ function Wizard({ onTarget={updateTarget} onSecretRef={updateSecret} onConnectedUser={(user) => - setDraft((d) => ({ - ...d, - rules: user === null ? d.rules : { ...d.rules, allow_list: [user.id] }, - })) + setDraft((d) => + user === null + ? d + : { + ...d, + rules: { + ...d.rules, + allow_list: [ + user.id, + ...(d.rules.allow_list ?? []).filter((id) => id !== user.id), + ], + }, + ...(user.name !== '' && { people: { ...d.people, [user.id]: user.name } }), + }, + ) } /> ); @@ -269,6 +282,8 @@ function Wizard({ readOnly={readOnly} onName={(name) => setDraft((d) => ({ ...d, name }))} onRules={(rules: NotificationChannelRules) => setDraft((d) => ({ ...d, rules }))} + connection={channel?.connection ?? null} + onStep={onStep} /> ); case 'preview': @@ -292,7 +307,7 @@ function Wizard({ description={ channel === null ? 'BrowserHive sends notifications through your own bot, webhook or topic.' - : `${platformOf(channel.kind).label} channel · ${channel.target_hint}` + : `${platformOf(channel.kind).label}${channel.kind === 'discord' && channel.mode === 'bot' ? ' bot' : ''} channel · ${channelWhere(channel)}` } learnMore="Channels deliver notifications to the platforms you choose. Secrets stay in environment variables; everything else is set here and kept in the database." learnMoreDocs={draft.kind === null ? 'notificationChannels' : platformOf(draft.kind).docs} diff --git a/packages/dashboard/src/features/notifications/channels/wizard/DiscordDifference.tsx b/packages/dashboard/src/features/notifications/channels/wizard/DiscordDifference.tsx index 80fdd8c..fb3a4ab 100644 --- a/packages/dashboard/src/features/notifications/channels/wizard/DiscordDifference.tsx +++ b/packages/dashboard/src/features/notifications/channels/wizard/DiscordDifference.tsx @@ -23,7 +23,7 @@ const ROWS: readonly { { label: 'Setup', webhook: 'About 30 seconds: Channel settings → Integrations → Webhooks → copy the URL.', - bot: 'About 3 minutes: create an application and a bot in the Developer Portal, invite it, pick the channel.', + bot: 'About 3 minutes: discord.com/developers → New Application → Bot → Reset Token; make it private (Installation → Install Link: None, then Bot → Public Bot off); invite it with the link the wizard shows; pick the channel. No privileged intents.', }, { label: 'Alerts, screenshots, updates, self-destruct', @@ -33,17 +33,17 @@ const ROWS: readonly { { label: 'Buttons', webhook: 'Links that open BrowserHive (Take over, Open session).', - bot: 'Approve, Deny, Mark resolved right in Discord, plus the links.', + bot: 'Approve, Reject and Mark resolved answer right in Discord (only for the people you allow), plus the links.', }, { label: 'Connection', webhook: 'None: BrowserHive only sends.', - bot: 'One outgoing connection to Discord while BrowserHive runs.', + bot: 'One outgoing connection to Discord while BrowserHive runs; nothing to open on your network. Presses made while BrowserHive is stopped fail in Discord.', }, { - label: 'Available', - webhook: 'Now', - bot: 'With the act-buttons release', + label: 'Who can answer', + webhook: 'Nobody from Discord; answers happen in BrowserHive.', + bot: 'You, after pressing "This is me" in the setup, and anyone whose Discord id you add.', }, ]; @@ -68,7 +68,12 @@ function ModeColumn({ readonly title: string; readonly badge: string; }) { - const preview = useChannelPreview({ kind: 'discord', mode, sample: 'attention' }); + // Bot mode is drawn with act buttons on, as a bot channel answers from Discord. + const preview = useChannelPreview( + mode === 'bot' + ? { kind: 'discord', mode, rules: { act_buttons: true }, sample: 'attention' } + : { kind: 'discord', mode, sample: 'attention' }, + ); return (
    @@ -76,9 +81,7 @@ function ModeColumn({ {badge} @@ -113,7 +116,7 @@ export function DiscordDifference({ open, onOpenChange }: DiscordDifferenceProps Webhook or bot: what's the difference? - A Discord channel uses one of the two. Both deliver the same alerts; a bot can also take + A Discord channel uses one of the two. Both deliver the same alerts; a bot also takes your decision from a button. You can switch later without losing the rules. @@ -159,8 +162,8 @@ export function DiscordDifference({ open, onOpenChange }: DiscordDifferenceProps
    - - + +

    Both messages are drawn by BrowserHive's own renderer for a sample attention request, in diff --git a/packages/dashboard/src/features/notifications/channels/wizard/StepConnect.tsx b/packages/dashboard/src/features/notifications/channels/wizard/StepConnect.tsx index 94ba6c0..34045db 100644 --- a/packages/dashboard/src/features/notifications/channels/wizard/StepConnect.tsx +++ b/packages/dashboard/src/features/notifications/channels/wizard/StepConnect.tsx @@ -1,16 +1,32 @@ -/** @module features/notifications/channels/wizard/StepConnect — step 3: Telegram's one-tap connect (a `t.me/?start=` link and QR code while the server waits two minutes for `/start`, then the captured chat and the person who connected it; a manual chat id as the fallback), Discord webhook (nothing more to connect), ntfy server and topic with the subscribe QR code, and the webhook URL (with the private-address note) */ +/** @module features/notifications/channels/wizard/StepConnect — step 3: Telegram's one-tap connect (a `t.me/?start=` link and QR code while the server waits two minutes for `/start`, then the captured chat and the person who connected it; a manual chat id as the fallback), Discord webhook (nothing more to connect) or bot (invite link, server and channel pickers, the "This is me" account link), ntfy server and topic with the subscribe QR code and the optional reply topic (D-42), and the webhook URL (with the private-address note) */ import { NTFY_DEFAULT_SERVER } from '@browserhive/contracts/notifications'; -import { useEffect, useId, useState } from 'react'; +import { type ReactNode, useEffect, useId, useState } from 'react'; import { Callout } from '@/components/shared/Callout.tsx'; import { Button, buttonVariants } from '@/components/ui/button.tsx'; import { Input } from '@/components/ui/input.tsx'; +import { SimpleSelect } from '@/components/ui/select.tsx'; import { Spinner } from '@/components/ui/spinner.tsx'; import { toAppError } from '@/lib/api/errors.ts'; import { ICONS } from '@/lib/icons.ts'; import { useServerNow } from '@/lib/server-now.ts'; import { cn } from '@/lib/utils.ts'; -import { useStartTelegramConnect, useTelegramConnect } from '../api.ts'; -import { type ChannelDraft, isPrivateUrl, isPublicNtfy, ntfyLinks, randomTopic } from '../model.ts'; +import { + useDiscordBot, + useDiscordChannels, + useDiscordConnect, + useStartDiscordConnect, + useStartTelegramConnect, + useTelegramConnect, +} from '../api.ts'; +import { + type ChannelDraft, + isPrivateUrl, + isPublicNtfy, + ntfyLinks, + randomReplyTopic, + randomTopic, + USER_ID_RE, +} from '../model.ts'; import { QrCode } from '../QrCode.tsx'; import { type EnvState, EnvVarField, Field, SwitchField } from './fields.tsx'; @@ -362,10 +378,177 @@ function NtfyConnect({

    ) : null} + ); } +/** The optional reply topic: act buttons post their token there (D-42). */ +function NtfyReplyTopic({ + draft, + onTarget, + onSecretRef, + envState, + checking, + errors, + readOnly, +}: Omit) { + const topicId = useId(); + const literal = draft.target['reply_topic']; + const fromEnv = draft.secretRefs['reply_topic'] !== undefined; + const on = fromEnv || (literal !== undefined && literal !== ''); + const tokenOn = draft.secretRefs['reply_token'] !== undefined; + const Refresh = ICONS.refresh; + const Answer = ICONS.answer; + const Shield = ICONS.secured; + return ( +
    +
    + +
    +

    + Answer from the notification{' '} + optional +

    +

    + With a reply topic, Approve and Reject become buttons on the notification: tapping one + makes the ntfy app post to this second topic, which BrowserHive listens to. Switch on + “Answer from the chat” in the next step to use it. +

    +
    +
    + { + if (checked) onTarget({ reply_topic: randomReplyTopic() }); + else { + onTarget({ reply_topic: null }); + onSecretRef('reply_topic', null); + onSecretRef('reply_token', null); + } + }} + > + Use a reply topic + + {on ? ( +
    + {!fromEnv ? ( + +
    + onTarget({ reply_topic: e.target.value.trim() })} + /> + +
    +
    + ) : ( + onSecretRef('reply_topic', v)} + state={envState(draft.secretRefs['reply_topic'] ?? '')} + checking={checking} + error={errors['secret_refs.reply_topic']} + /> + )} +
    + { + if (checked) { + onSecretRef('reply_topic', 'BH_NTFY_REPLY_TOPIC'); + onTarget({ reply_topic: null }); + } else { + onSecretRef('reply_topic', null); + onTarget({ reply_topic: randomReplyTopic() }); + } + }} + > + Keep the reply topic in an environment variable + + + onSecretRef('reply_token', checked ? 'BH_NTFY_REPLY_TOKEN' : null) + } + > + Read it with its own access token (otherwise the channel's token is used) + + {tokenOn ? ( + onSecretRef('reply_token', v)} + state={envState(draft.secretRefs['reply_token'] ?? '')} + checking={checking} + error={errors['secret_refs.reply_token']} + /> + ) : null} +
    + + ntfy has no accounts: whoever can read your notification topic can press its buttons, so + keep that topic private. Someone who only learns the reply topic can post to it but + cannot act: every button carries a one-time code BrowserHive checks. + + + +
    + ) : null} +
    + ); +} + function WebhookConnect({ draft, onTarget, @@ -467,13 +650,433 @@ function DiscordConnect({ draft, envState }: PartProps) { ); } +/** One numbered step of a setup sequence (done steps show a check). */ +function SetupStep({ + n, + title, + done, + children, + aside, +}: { + readonly n: number; + readonly title: string; + readonly done: boolean; + readonly children: ReactNode; + readonly aside?: ReactNode; +}) { + const Check = ICONS.check; + return ( +
  • + +
    +
    +

    + + Step {n} + {done ? ' (done)' : ''}:{' '} + + {title} +

    + {aside} +
    + {children} +
    +
  • + ); +} + +/** Discord bot mode: invite the bot, pick the server and channel, link the operator's account (D-38). */ +/** The fallback to the "This is me" press: your Discord user id, typed in. */ +function ManualUserId({ onAdd }: { readonly onAdd: (id: string) => void }) { + const inputId = useId(); + const [value, setValue] = useState(''); + const [error, setError] = useState(null); + const [added, setAdded] = useState(null); + const add = () => { + const id = value.trim(); + if (!USER_ID_RE.test(id)) { + setError('A Discord user id is a number, like 123456789012345678.'); + return; + } + onAdd(id); + setAdded(id); + setValue(''); + setError(null); + }; + return ( +
    + + Add your user id by hand instead + +
    + +
    + { + setValue(e.target.value.trim()); + setError(null); + setAdded(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + add(); + } + }} + /> + +
    + {error !== null ? ( +

    + {error} +

    + ) : added !== null ? ( +

    + Added {added} to the allowed people. +

    + ) : ( +

    + In Discord, turn on Developer Mode (User Settings → Advanced), then right-click your + name and choose Copy User ID. +

    + )} +
    +
    + ); +} + +function DiscordBotConnect({ draft, onTarget, envState, readOnly, onConnectedUser }: PartProps) { + const tokenEnv = draft.secretRefs['token'] ?? ''; + const tokenSet = tokenEnv !== '' && envState(tokenEnv) === true; + const bot = useDiscordBot(tokenEnv, tokenSet); + const guildId = draft.target['guild_id'] ?? null; + const channels = useDiscordChannels(tokenEnv, guildId); + const start = useStartDiscordConnect(); + const [connectId, setConnectId] = useState(null); + const status = useDiscordConnect(connectId); + const [manual, setManual] = useState(false); + const serverId = useId(); + const channelSelectId = useId(); + const manualId = useId(); + const state = status.data; + const channelId = draft.target['channel_id'] ?? ''; + const Invite = ICONS.external; + const Refresh = ICONS.refresh; + const Ok = ICONS.success; + const Link = ICONS.connect; + const Bot = ICONS.platformDiscord; + const linked = + (state?.status === 'connected' && state.user !== null) || + (draft.rules.allow_list ?? []).length > 0; + + // Applied once per link: onConnectedUser is a stable wizard setter. + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the link outcome only + useEffect(() => { + if (state?.status === 'connected' && state.user !== null) onConnectedUser(state.user); + }, [state?.status, state?.user?.id]); + + // A bot in exactly one server: that server is the only choice. + const onlyGuild = bot.data?.guilds.length === 1 ? bot.data.guilds[0] : undefined; + // biome-ignore lint/correctness/useExhaustiveDependencies: onTarget is a stable wizard setter + useEffect(() => { + if (onlyGuild !== undefined && guildId === null && !readOnly) { + onTarget({ guild_id: onlyGuild.id, guild_name: onlyGuild.name }); + } + }, [onlyGuild?.id, guildId, readOnly]); + + if (!tokenSet) { + return ( + + The setup talks to Discord with the bot token in{' '} + {tokenEnv === '' ? 'its variable' : tokenEnv}. Go back to Credentials, set it and restart + BrowserHive. + + ); + } + const guilds = bot.data?.guilds ?? []; + const channelList = channels.data?.channels ?? []; + const selectedChannel = channelList.find((c) => c.id === channelId); + const channelName = + selectedChannel !== undefined + ? `#${selectedChannel.name}` + : draft.target['channel_name'] !== undefined + ? `#${draft.target['channel_name']}` + : 'the channel'; + return ( +
    +
      + 0} + aside={ + bot.data !== undefined ? ( + + + ) : bot.isError ? ( + + ) : null + } + > + {bot.isPending ? ( +

      + Asking Discord who the bot is… +

      + ) : bot.isError ? ( +
      +

      Discord did not accept the bot

      +

      + {toAppError(bot.error).details['code'] === 'auth' + ? `Discord refused the token in ${tokenEnv}. Reset it in the Developer Portal (Bot → Reset Token), update the variable and restart BrowserHive.` + : describeError(bot.error)} +

      +
      + ) : ( + <> +
      + + +
      + {bot.data?.bot_username} + + {guilds.length === 0 + ? 'Not in a server yet.' + : `In ${guilds.length} ${guilds.length === 1 ? 'server' : 'servers'}.`} + +
      +
      +

      + The link asks only for what BrowserHive uses: View Channels, Send Messages, Embed + Links and Attach Files. It works for you as the application's owner even when the + bot is private. Pick your server, press Authorize, then come back and refresh the + server list. +

      +
      + + Invite it by hand instead + +

      + In the Developer Portal, open{' '} + OAuth2 → URL Generator, tick the scope{' '} + bot only (not applications.commands), choose + Guild Install if asked, tick View Channels, Send Messages, Embed Links and Attach + Files (Read Message History is optional; BrowserHive does not need it), open the + URL, pick your server and Authorize. +

      +
      + + )} +
      + + + {bot.data !== undefined ? ( +
      + +
      + ({ value: g.id, label: g.name }))} + onValueChange={(id) => + onTarget({ + guild_id: id, + guild_name: guilds.find((g) => g.id === id)?.name ?? null, + channel_id: null, + channel_name: null, + }) + } + /> + +
      +
      + + ({ + value: c.id, + label: c.category === null ? `#${c.name}` : `#${c.name} · ${c.category}`, + }))} + onValueChange={(id) => + onTarget({ + channel_id: id, + channel_name: channelList.find((c) => c.id === id)?.name ?? null, + }) + } + /> + +
      + ) : channelId !== '' ? ( +

      + Posting to {channelName} + {draft.target['guild_name'] !== undefined ? ` in ${draft.target['guild_name']}` : ''}. +

      + ) : ( +

      + The pickers appear once Discord accepts the bot. +

      + )} +
      + + {manual ? ( + + onTarget({ channel_id: e.target.value.trim() || null })} + /> + + ) : null} +
      +
      + + + start.mutate( + { tokenEnv, channelId }, + { onSuccess: (result) => setConnectId(result.connect_id) }, + ) + } + > + {start.isPending ? : } + {state === undefined && !linked ? 'Send the link message' : 'Link another account'} + + ) : null + } + > +

      + The bot posts a This is me button + in {channelName}. Press it within two minutes: your account becomes the first person + allowed to answer from Discord. The message is removed afterwards. +

      + {state?.status === 'waiting' ? ( +

      + Waiting for your press in {channelName} ·{' '} + +

      + ) : state?.status === 'connected' && state.user !== null ? ( +

      +

      + ) : state?.status === 'expired' ? ( +

      Nobody pressed the button in time.

      + ) : state?.status === 'failed' ? ( +

      + {state.error ?? 'Discord refused the request.'} +

      + ) : start.isError ? ( +

      + {describeError(start.error)} +

      + ) : channelId === '' ? ( +

      Choose the channel first.

      + ) : null} + {!readOnly ? onConnectedUser({ id, name: '' })} /> : null} +
      +
    +
    + ); +} + /** Step 3. */ export function StepConnect(props: PartProps) { switch (props.draft.kind) { case 'telegram': return ; case 'discord': - return ; + return props.draft.mode === 'bot' ? ( + + ) : ( + + ); case 'ntfy': return ; case 'webhook': diff --git a/packages/dashboard/src/features/notifications/channels/wizard/StepCredentials.tsx b/packages/dashboard/src/features/notifications/channels/wizard/StepCredentials.tsx index 1a256a9..54665a8 100644 --- a/packages/dashboard/src/features/notifications/channels/wizard/StepCredentials.tsx +++ b/packages/dashboard/src/features/notifications/channels/wizard/StepCredentials.tsx @@ -3,9 +3,63 @@ import { CHANNEL_KIND_SPECS, type SecretParamSpec } from '@browserhive/contracts import type { ReactNode } from 'react'; import { Callout } from '@/components/shared/Callout.tsx'; import { ICONS } from '@/lib/icons.ts'; -import type { ChannelDraft } from '../model.ts'; +import { docsUrl } from '@/lib/links.ts'; +import { type ChannelDraft, CONNECT_SECRETS, modeSecrets } from '../model.ts'; import { type EnvState, EnvVarField, LaunchInstructions, SwitchField } from './fields.tsx'; +const DISCORD_BOT_WHERE: ReactNode = ( +
    +
      +
    1. + Open{' '} + + discord.com/developers + {' '} + → New Application (name it BrowserHive) →{' '} + Bot tab →{' '} + Reset Token. Discord shows the token once: put it in + the variable below, never in this page. +
    2. +
    3. + Make the bot private, in this order: Installation tab → + Install Link → None → Save; only then{' '} + Bot tab → turn{' '} + Public Bot off → Save. +
    4. +
    5. + Leave every Privileged Gateway Intent off: button + presses arrive without them. +
    6. +
    +
    + + Discord says “Private application cannot have a default authorization link” + +

    + Public Bot was turned off while an install link was still set. Open the{' '} + Installation tab, set the Install Link to{' '} + None, Save, then turn Public Bot off again.{' '} + + More fixes + +

    +
    +

    + The next step invites the bot with the least it needs and picks the channel for you. +

    +
    +); + const WHERE: Readonly> = { telegram: (
      @@ -79,9 +133,10 @@ export function StepCredentials({ }: StepCredentialsProps) { if (draft.kind === null) return null; const spec = CHANNEL_KIND_SPECS[draft.kind]; - const params: readonly SecretParamSpec[] = spec.secrets.filter( - (s) => !spec.eitherTargetOrSecret.includes(s.param), + const params: readonly SecretParamSpec[] = modeSecrets(draft.kind, draft.mode).filter( + (s) => !spec.eitherTargetOrSecret.includes(s.param) && !CONNECT_SECRETS.has(s.param), ); + const bot = draft.kind === 'discord' && draft.mode === 'bot'; const names = params .map((p) => draft.secretRefs[p.param]) .filter((n): n is string => n !== undefined && n !== ''); @@ -94,7 +149,7 @@ export function StepCredentials({ className="flex flex-col gap-2 rounded-xl border bg-muted/40 p-4 dark:bg-white/[0.02]" >

      Where to get it

      - {WHERE[draft.kind]} + {bot ? DISCORD_BOT_WHERE : WHERE[draft.kind]}
      @@ -131,7 +186,7 @@ export function StepCredentials({ return ( Webhook - 30-second setup. Alerts, screenshots, live updates and link buttons. + About 30 seconds. Alerts, screenshots, live updates and buttons that open + BrowserHive. @@ -166,12 +167,13 @@ export function StepPlatform({ draft, onKind, onMode, locked }: StepPlatformProp Bot - - coming later + + answer from Discord - Approve or deny right in Discord. Arrives with act buttons. + About 3 minutes. Everything a webhook does, plus Approve, Reject and Mark resolved + buttons that work right in Discord. diff --git a/packages/dashboard/src/features/notifications/channels/wizard/StepPreview.tsx b/packages/dashboard/src/features/notifications/channels/wizard/StepPreview.tsx index e34b946..9e6fabc 100644 --- a/packages/dashboard/src/features/notifications/channels/wizard/StepPreview.tsx +++ b/packages/dashboard/src/features/notifications/channels/wizard/StepPreview.tsx @@ -68,6 +68,7 @@ export function StepPreview({ draft, channelId, ready, readOnly }: StepPreviewPr kind: draft.kind, mode: draft.mode, target: { ...draft.target }, + secret_refs: { ...draft.secretRefs }, rules: cleanRules(draft.rules), sample, }, diff --git a/packages/dashboard/src/features/notifications/channels/wizard/StepRules.tsx b/packages/dashboard/src/features/notifications/channels/wizard/StepRules.tsx index 7254d23..624d417 100644 --- a/packages/dashboard/src/features/notifications/channels/wizard/StepRules.tsx +++ b/packages/dashboard/src/features/notifications/channels/wizard/StepRules.tsx @@ -1,5 +1,6 @@ /** @module features/notifications/channels/wizard/StepRules — step 4: the channel's name and what it sends: preset cards (Needs me now, Problems, Wrap-ups, Everything) and an Advanced disclosure with categories, minimum severity, session globs, harness, quiet hours with a time zone, content level, screenshots per category with masking (need Full; the ntfy.sh warning), self-destruct per category (Never by default, Telegram at most 47 h) and delete-when-resolved (off by default) (D-35, D-36) */ import type { NotificationCategory, NotificationContentLevel } from '@browserhive/contracts/enums'; +import type { ChannelConnection } from '@browserhive/contracts/http'; import { CHANNEL_PRESETS, type NotificationChannelRules, @@ -20,6 +21,7 @@ import { presetOf, ttlChoices, } from '../model.ts'; +import { ActButtonsSection } from './ActButtonsSection.tsx'; import { Field, SwitchField } from './fields.tsx'; import { RadioCard } from './StepPlatform.tsx'; @@ -67,6 +69,10 @@ export interface StepRulesProps { readonly onRules: (rules: NotificationChannelRules) => void; readonly errors: Readonly>; readonly readOnly: boolean; + /** The saved channel's press listener, when editing. */ + readonly connection?: ChannelConnection | null; + /** Opens another wizard step (the act-button blockers point at Platform or Connect). */ + readonly onStep?: (step: 'platform' | 'connect') => void; } function PerCategorySwitches({ @@ -109,11 +115,22 @@ function PerCategorySwitches({ } /** Step 4. */ -export function StepRules({ draft, onName, onRules, errors, readOnly }: StepRulesProps) { +export function StepRules({ + draft, + onName, + onRules, + errors, + readOnly, + connection = null, + onStep, +}: StepRulesProps) { const rules = draft.rules; const preset = presetOf(rules); const [advanced, setAdvanced] = useState( - preset === null || Object.keys(rules).some((k) => k !== 'categories'), + preset === null || + Object.keys(rules).some( + (k) => k !== 'categories' && k !== 'act_buttons' && k !== 'allow_list', + ), ); const nameId = useId(); const categoriesId = useId(); @@ -173,6 +190,15 @@ export function StepRules({ draft, onName, onRules, errors, readOnly }: StepRule ) : null} + +