diff --git a/AGENTS.md b/AGENTS.md index 59ec12d..5c0a286 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ --- title: Agent Guidance -last_modified: 2026-09-26 +last_modified: 2026-09-29 --- This file is the single source of guidance for any automated agent @@ -118,9 +118,11 @@ Do not weaken them. ## Layout ``` -cmd/simplexcalc/ cobra command tree; main(), run and version +cmd/simplexcalc/ main(), a single call into internal/cli +internal/api/ the HTTP API: its credential, headers and endpoints internal/bot/ startup, address setup, and the reply to a message -internal/calc/ the arithmetic: go/parser and go/constant +internal/calc/ the arithmetic: its own parser, and go/constant +internal/cli/ cobra command tree: run and version internal/config/ viper-backed configuration; the abort-on-garbage rule internal/logger/ log/slog, JSON always internal/simplex/ the simplex-chat child process and its WebSocket API diff --git a/Dockerfile b/Dockerfile index 3238e1a..a4ce457 100644 --- a/Dockerfile +++ b/Dockerfile @@ -109,9 +109,9 @@ WORKDIR /app COPY --from=builder /build/bin/simplexcalc /app/simplexcalc # Data directory: the SimpleX database, which holds the bot's profile, -# its keys, its address and its contacts. Mount a volume over it; -# without one, the bot gets a new address every time the container is -# recreated. +# its keys, its address and its contacts, and the API's webhooks. Mount +# a volume over it; without one, the bot gets a new address and loses +# its webhooks every time the container is recreated. RUN mkdir -p /var/lib/simplexcalc && \ chown simplexcalc:simplexcalc /var/lib/simplexcalc @@ -119,4 +119,8 @@ USER simplexcalc ENV DATA_DIR=/var/lib/simplexcalc +# The API, on its default PORT. The chat client's WebSocket on 5225 is +# not exposed: it has no authentication. +EXPOSE 8080 + CMD ["/app/simplexcalc", "run"] diff --git a/README.md b/README.md index 80aa58b..ee92ddd 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,35 @@ SimpleX Chat network: it accepts every contact request and answers arithmetic such as `2 + 2` with the result. Send it `2 + 2` and it replies `4`; send `5 * 5/2` and it replies -`12.5`. It understands decimal numbers, `+ - * /`, unary minus and -parentheses, and computes exactly, so `0.1 + 0.2` is `0.3`. Anything -else gets a short explanation instead of a result. +`12.5`. It understands decimal numbers, `+ - * /`, powers written `2^10` +or `2**10`, remainders written `7 % 3`, signs and parentheses, and +computes exactly, so `0.1 + 0.2` is `0.3`. Anything else gets a short +explanation instead of a result. ## Getting Started -Build the image and run the bot, with its SimpleX profile on a named -volume: +Build the image, write a random API credential onto a named volume, and +run the bot with its SimpleX profile on the same volume: ```sh -git clone git@git.eeqj.de:clawbot/simplexcalc.git +git clone git@git.eeqj.de:sneak/simplexcalc.git cd simplexcalc make docker +docker run --rm -v simplexcalc-data:/var/lib/simplexcalc simplexcalc \ + sh -c 'umask 077 && od -An -N32 -tx1 /dev/urandom | tr -d " \n" \ + >/var/lib/simplexcalc/api-token' docker run -d --name simplexcalc --restart unless-stopped \ - -v simplexcalc-data:/var/lib/simplexcalc simplexcalc + -v simplexcalc-data:/var/lib/simplexcalc \ + -p 127.0.0.1:8080:8080 -e API_TOKEN_FILE=/var/lib/simplexcalc/api-token \ + simplexcalc docker logs simplexcalc 2>&1 | grep '"msg":"ready"' ``` +The credential is 32 random bytes written as 64 hexadecimal characters +to `/var/lib/simplexcalc/api-token`, readable only by the bot's user. +`-p 127.0.0.1:8080:8080` makes the API reachable from this host only; +see [API](#api). + The `ready` log line carries the bot's contact address: `address` is the short link to share, and `full_address` is the same address in the long form that older SimpleX clients need. Open the link in any SimpleX Chat @@ -62,11 +73,281 @@ development. is never quietly replaced by the default. Defaults apply only to variables that are absent. -- `DATA_DIR` — where the SimpleX database lives: the bot's profile, its - keys, its address and its contacts. Default `./data`; the image sets +- `DATA_DIR` — where the bot keeps what it must not lose: the SimpleX + database, which holds the bot's profile, its keys, its address and its + contacts, and `webhooks.json`, which holds the webhooks registered + through the [API](#api). Default `./data`; the image sets `/var/lib/simplexcalc`. - `DEBUG` — `true` or `false`, default `false`. `true` logs every event the chat client sends. +- `PORT` — the TCP port the API listens on, on all interfaces: a whole + number from 1 to 65535, default `8080`. +- `API_TOKEN_FILE` — path of a file holding the API credential, at least + 32 characters not counting the whitespace around them. The file is + read once, at startup; one that cannot be read, or holds a shorter + credential, aborts startup. Absent, the API still listens but refuses + every request, and startup logs a warning saying so. + +## API + +An HTTP API beside the chat client lets another program read the bot's +chats, send messages in them and register webhooks on them. It speaks +JSON on `PORT`. + +**Authentication.** Every request carries the credential from +`API_TOKEN_FILE`: + +``` +Authorization: Bearer {credential} +``` + +A request without it, or with a wrong one, gets `401` with +`WWW-Authenticate: Bearer` and `{"error":"unauthorized"}`. No path is +exempt. Without `API_TOKEN_FILE`, every request gets that answer. The +file is read at startup, so a new credential takes a restart. + +**Errors** are JSON with a short explanation, such as +`{"error":"not found"}`; what went wrong inside goes to the bot's log. + +### `GET /api/v1/chats` + +The bot's chats, ordered by `id`. The bot talks to people only one to +one, so each chat is one of its contacts. + +```sh +TOKEN=$(docker exec simplexcalc cat /var/lib/simplexcalc/api-token) +curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/api/v1/chats +``` + +```json +{ + "chats": [ + { "id": 3, "display_name": "tester", "contact_deleted": false }, + { "id": 4, "display_name": "tester", "contact_deleted": true } + ] +} +``` + +- `id` — the chat's number, which is its contact's number in the chat + client. +- `display_name` — the name the contact gave themselves. Nothing makes + it unique. +- `contact_deleted` — `true` once the contact has deleted their chat + with the bot. The chat stays in the list, but nothing more reaches + them. + +### `GET /api/v1/chats/{id}/messages` + +The latest messages in the chat `id`, oldest first. `count`, a whole +number from 1 to 100 and 20 if absent, is how many of the chat's latest +items to read. The chat client also records events in a chat, such as +the contact connecting, and only messages are returned, so fewer than +`count` can come back. + +```sh +curl -H "Authorization: Bearer $TOKEN" \ + 'http://127.0.0.1:8080/api/v1/chats/3/messages?count=5' +``` + +```json +{ + "messages": [ + { + "id": 9, + "direction": "received", + "type": "text", + "text": "2 + 2", + "time": "2026-09-29T03:14:34Z" + }, + { + "id": 10, + "direction": "sent", + "type": "text", + "text": "4", + "time": "2026-09-29T03:14:35.101223457Z" + } + ] +} +``` + +A message has: + +- `id` — its number, unique across all the bot's chats. +- `direction` — `received` from the contact, or `sent` by the bot. +- `type` — `text`, or another kind of SimpleX message, such as `image`, + `file` or `voice`. +- `text` — the text; for a message that is not `text`, its caption, + which can be empty. +- `time` — in RFC 3339, in UTC: for a received message, when it reached + the SimpleX relay, to the second; for a sent one, when the bot sent + it. + +The answer is `400` if `count` is not a whole number from 1 to 100, and +`404` if `GET /api/v1/chats` does not list `id`. A query the server +cannot read gets `400` with `the query cannot be read`; examples are a +query that holds a `;`, a `%` not followed by two hexadecimal digits, or +more than 10,000 parts separated by `&`. + +### `POST /api/v1/chats/{id}/messages` + +Sends the `text` in the body to the chat `id`, and answers `201` with +the message as sent. The answer comes once the chat client has taken the +message, before it reaches the contact. + +```sh +curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"text":"hello"}' http://127.0.0.1:8080/api/v1/chats/3/messages +``` + +```json +{ + "message": { + "id": 12, + "direction": "sent", + "type": "text", + "text": "hello", + "time": "2026-09-29T03:14:43.519552587Z" + } +} +``` + +Nothing is sent, and the answer is: + +- `400` if the body is not JSON of that shape, or `text` is empty; +- `404` if `GET /api/v1/chats` does not list `id`; +- `409` if the contact cannot receive messages: they have deleted their + chat with the bot (`contact_deleted` is `true`), or have not finished + connecting; +- `413` if the body is over 64 KiB, or the text is too long for one + SimpleX message, which holds about 15,000 bytes. + +### `POST /api/v1/chats/{id}/webhooks` + +Registers the `url` in the body as a webhook on the chat `id`, and +answers `201` with the webhook. If the chat already has a webhook with +the same `url`, character for character, the answer is `200` with that +webhook instead. Each new message the contact sends in the chat is then +posted to the webhook, as +[Messages posted to webhooks](#messages-posted-to-webhooks) describes. + +```sh +curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"url":"https://example.com/hook"}' \ + http://127.0.0.1:8080/api/v1/chats/3/webhooks +``` + +```json +{ + "id": "5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80", + "chat_id": 3, + "url": "https://example.com/hook" +} +``` + +A webhook has: + +- `id` — 16 random bytes, written as 32 hexadecimal digits. +- `chat_id` — the chat it is registered on. +- `url` — the URL, as it was registered. + +The bot keeps the webhooks in `webhooks.json` in `DATA_DIR`, with mode +0600, and reads that file at startup, so they survive a restart. Without +the file there are none; a file that cannot be read, or that holds +anything but webhooks as the bot writes them, aborts startup, as +configuration that cannot be parsed does. + +Nothing is registered, and the answer is: + +- `400` if the body is not JSON of that shape, or `url` is not an + absolute `http` or `https` URL with a host, at most 2048 bytes long; +- `404` if `GET /api/v1/chats` does not list `id`; +- `413` if the body is over 64 KiB; +- `500` if `webhooks.json` cannot be written. + +### `GET /api/v1/chats/{id}/webhooks` + +The webhooks registered on the chat `id`, in the order they were +registered; `404` if `GET /api/v1/chats` does not list `id`. A chat's +webhooks stay after its contact deletes the chat (`contact_deleted` is +`true`), until they are removed. + +```sh +curl -H "Authorization: Bearer $TOKEN" \ + http://127.0.0.1:8080/api/v1/chats/3/webhooks +``` + +```json +{ + "webhooks": [ + { + "id": "5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80", + "chat_id": 3, + "url": "https://example.com/hook" + } + ] +} +``` + +### `DELETE /api/v1/chats/{id}/webhooks/{webhook_id}` + +Removes the webhook `webhook_id` from the chat `id`, and answers `204` +with no body. + +```sh +curl -X DELETE -H "Authorization: Bearer $TOKEN" \ + http://127.0.0.1:8080/api/v1/chats/3/webhooks/5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80 +``` + +Nothing is removed, and the answer is: + +- `404` if `GET /api/v1/chats` does not list `id`, or the chat has no + webhook `webhook_id`; +- `500` if `webhooks.json` cannot be written. + +### Messages posted to webhooks + +Each new message a contact sends in a chat is posted to each of the +chat's webhooks: a `POST` to its `url`, with +`Content-Type: application/json`, whose body is the chat's `id` as +`chat_id` and the message as `GET /api/v1/chats/{id}/messages` shows it: + +```json +{ + "chat_id": 3, + "message": { + "id": 9, + "direction": "received", + "type": "text", + "text": "2 + 2", + "time": "2026-09-29T03:14:34Z" + } +} +``` + +Every kind of message is posted, not only arithmetic, so `type` can be +`image`, `file` or another kind, with the caption as `text`. Messages +the bot sends, its replies and those sent through the API, are not +posted. + +A message is posted to each webhook at most once: there are no retries. +A webhook misses it if the bot cannot reach it, if it does not answer +within 10 seconds, or if it answers with a status other than 2xx; a +redirect is not followed, and counts as such a status. So that no +webhook holds up the bot's replies, messages wait in a queue, and the +bot posts four at a time; a message that arrives while 100 wait is not +posted. Posting goes on while the bot stops; once its chat client has +exited, posts in progress are abandoned, and messages still waiting are +not posted. + +The bot logs two warnings, which never hold a webhook's URL, as it can +carry a secret, nor a message's text: + +- `dropping a message for the webhooks: the queue is full`, with + `chat_id` and `message_id`, for a message that found the queue full; +- `posting a message to a webhook`, with `webhook_id`, `message_id` and + either the `status` the webhook answered with or the `error` that + ended the post, for each post that got no 2xx answer, abandoned posts + included. The error can name the webhook's host. ## Entrypoints @@ -142,8 +423,8 @@ container. - **One process tree, one container.** `simplexcalc run` starts the SimpleX Chat command-line client, `simplex-chat`, as a child process, with its database under `$DATA_DIR/simplex` and its WebSocket API on - `127.0.0.1:5225`. The API has no authentication, which is why it is - never exposed outside the container. + `127.0.0.1:5225`. That WebSocket has no authentication, which is why + it is never exposed outside the container. - **The protocol** (`internal/simplex`) is JSON over that WebSocket: a command carries a correlation id, its response carries the same id, and anything without one is an event. Only the fields the bot reads @@ -154,19 +435,66 @@ container. accept every contact request and to greet each new contact. The first start creates the profile itself, a bot profile named `calc`. The address is logged in the `ready` line. +- **The API** (`internal/api`): an HTTP server, routed with chi, that + starts once set-up is done; if it cannot listen, the bot exits with an + error, as when the chat client fails. Every request must carry the + credential, compared in constant time; every response carries headers + that forbid framing, content sniffing, caching and referrers, and a + `Permissions-Policy` that denies the camera, microphone and location; + a request body is capped at 64 KiB and a request's work at 10 seconds. + Handlers call the chat client on the request's own goroutine, never on + the one that delivers events, which also delivers the chat client's + answers. When the bot stops, requests in progress get 5 seconds to + finish before the chat client is stopped. +- **Webhooks** (`internal/api`) are read from `$DATA_DIR/webhooks.json` + before the chat client starts. Changes are made one at a time, and + each rewrites the file whole: the bot writes a temporary file beside + it, named `webhooks.json.` and digits, and renames it over the old + one. A crash leaves the old file or the new one, never part of one, + and at worst a stray temporary file, which the bot ignores. Messages + are posted to the webhooks by four goroutines serving a queue. The + goroutine that delivers the chat client's events, and also its + answers, only puts each message in the queue, never waiting: not for a + webhook, and not for the list of webhooks, which waits while the file + is written. Posting goes on while the bot stops, until the chat client + has exited. - **Replies**: for each text message a contact sends in a direct chat, the bot sends back the result, as a reply quoting the message. Group messages, files and the bot's own messages are ignored. -- **Arithmetic** (`internal/calc`): the text is parsed as a Go - expression with `go/parser`, and only numbers, `+ - * /`, unary signs - and parentheses are evaluated; anything else in the syntax tree is - refused. `go/constant` computes with exact rationals. Numbers are read - as decimal, so `010` is ten. Input over 256 bytes is refused, so a +- **Arithmetic** (`internal/calc`): a small parser of its own reads + numbers, `+ - * / % ^`, signs and parentheses, and refuses anything + else. `go/constant` computes with exact rationals. `^`, also written + `**`, is a power: it binds tighter than `*`, `/`, `%` and a sign on + its left, and groups to the right, so `2^3^2` is `512`, `-2^2` is + `-4`, `(-2)^2` is `4` and `2^-1` is `0.5`. `%` is the remainder and + ranks with `*` and `/`; its result takes the sign of the divisor, as + in Python, so `7 % 3` is `1`, `-7 % 3` is `2` and `7.5 % 2` is `1.5`. + A power with a whole exponent is exact, so `0.1^2` is `0.01` and + `2^-1400 * 2^1400` is `1`, unless `go/constant` could hold the result + only rounded; that power, and one with a fractional exponent, is + computed as a double, so `2^0.5` is `1.4142135623730951`. A negative + number to a fractional power is refused, as having no real result. + Numbers are read as decimal, so `010` is ten. Input over 256 bytes is + refused, exact powers are capped, and every number is held as a + fraction, whole numbers too, under the 4096-bit limit below, so a message cannot make the bot do unbounded work. Whole numbers below - 1021 are written exactly; other results in the shortest - form that reads back as the same double, in exponent notation from - 1021 up and below 10-6. A result beyond the - range of a double is refused as too large. + 1021 are written exactly. Other results inside the normal + range of a double, about 2.2e-308 to 1.8e308 in magnitude, where a + double keeps all its digits, are written in the shortest form that + reads back as the same double, in exponent notation from + 1021 up and below 10-6. Results outside that + range are written from their exact value in exponent notation, rounded + to 17 significant digits, the most the shortest form of a double + takes, with trailing zeros dropped: `2^1200` is + `1.7218479456385751e+361`, `10^400` is `1e+400` and `2^-1074` is + `4.9406564584124654e-324`. Refused as too large or too small: any + number whose numerator or denominator reaches 4096 bits, wherever it + appears, as `go/constant` rounds a fraction that grows that large + (`2^4095`, `1e-1300 + 1`); and a power computed as a double whose + result is outside the normal range of a double (`2^1500.5`). The base + of such a power may be outside that range: square roots taken from its + exact value bring it inside first, so `(2^1200)^0.5` is + `4.149515568880993e+180` and `1e-400^0.5` is `1e-200`. - **Failure is an exit.** If the chat client exits or the connection to it drops, the bot exits with an error and the container's restart policy starts both again. `SIGTERM` stops the bot, which stops the @@ -180,11 +508,14 @@ container. ## Operating it -**Backup.** Everything durable is the SimpleX database on the volume: -`simplex_chat.db` and `simplex_agent.db`. Stop the container before -copying them, since a copy taken from under the running client can be -inconsistent. The database holds the bot's keys, so a copy lets its -holder answer as the bot; keep it as private as the running instance. +**Backup.** Everything durable is on the volume: the SimpleX database, +`simplex_chat.db` and `simplex_agent.db`; the API credential, +`api-token`; and the webhooks, `webhooks.json`. Stop the container +before copying the database, since a copy taken from under the running +client can be inconsistent. The database holds the bot's keys, so a copy +lets its holder answer as the bot; the credential lets its holder use +the API; and a webhook's URL can hold a secret of the program it points +at. Keep all three as private as the running instance. **Upgrade.** Rebuild the image and recreate the container with the same volume. The chat client migrates its database on start. A newer diff --git a/cmd/simplexcalc/main.go b/cmd/simplexcalc/main.go index ff0dbed..bb06922 100644 --- a/cmd/simplexcalc/main.go +++ b/cmd/simplexcalc/main.go @@ -2,12 +2,10 @@ package main import ( - "fmt" "os" -) -// appname is the name the program reports in its logs and its version. -const appname = "simplexcalc" + "sneak.berlin/go/simplexcalc/internal/cli" +) // version is injected at build time with -ldflags "-X main.version=...". // The Dockerfile passes VERSION; a `go build` without it says "dev", @@ -16,11 +14,5 @@ const appname = "simplexcalc" var version = "dev" func main() { - err := rootCmd().Execute() - if err != nil { - // cobra has already printed the error; this only sets the exit - // status, which is what a supervisor and a shell script read. - fmt.Fprintln(os.Stderr, "exiting: "+err.Error()) - os.Exit(1) - } + os.Exit(cli.Main(version)) } diff --git a/docs/TODO.md b/docs/TODO.md index 970fe8a..1c1ef96 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -27,6 +27,23 @@ with no deprecation warning. # Completed Steps +- 2026-09-29 Exact results past the range of a double, such as `2^1200`, + are written to 17 significant digits instead of being refused, and a + fractional power of such a number, such as `(2^1200)^0.5`, is answered +- 2026-09-29 Each new incoming message is posted to its chat's webhooks +- 2026-09-29 `POST`, `GET` and `DELETE` on a chat's webhooks, under + `/api/v1/chats/{id}/webhooks`, kept in `$DATA_DIR/webhooks.json` +- 2026-09-29 `GET` and `POST /api/v1/chats/{id}/messages`: a chat's + latest messages, and sending one +- 2026-09-29 Added the HTTP API in `internal/api`: the server on `PORT`, + the bearer credential from `API_TOKEN_FILE`, security headers and + limits, and `GET /api/v1/chats` +- 2026-09-28 Powers (`^`, also written `**`) and remainders (`%`) in + `internal/calc`, which now reads expressions with a parser of its own + in place of `go/parser` +- 2026-09-28 Moved the command tree and the `run` and `version` commands + from `cmd/simplexcalc/` into `internal/cli`; `cmd/simplexcalc/main.go` + is now a single call to `cli.Main` - 2026-09-26 Seeded from `go-template-repo` (renamed, its HTTP service removed) and built the bot: `internal/calc` for the arithmetic, `internal/simplex` for the chat client process and its WebSocket API, diff --git a/go.mod b/go.mod index f0c9abc..4e77682 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module sneak.berlin/go/simplexcalc go 1.25.0 require ( + github.com/go-chi/chi/v5 v5.3.2 github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 1fd508c..0bcca44 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..b4d6118 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,243 @@ +// Package api is the bot's HTTP API, through which another program +// reads the bot's chats, sends messages in them and registers webhooks +// on them. Deliveries posts to those webhooks each message a contact +// sends. +// +// Every request must carry the credential, as "Authorization: Bearer +// {credential}"; with no credential configured, every request is +// refused. No path is exempt. +// +// Handlers call the chat client on the request's own goroutine. Doing +// so from the chat client's event handler would wait forever, since +// that goroutine also delivers the responses (see simplex.EventHandler). +// For the same reason, the event handler hands messages to Deliveries +// without waiting. +package api + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +const ( + // maxBodyBytes caps a request body. + maxBodyBytes = 64 << 10 + + // requestTimeout bounds the work behind one request, which is + // mostly waiting for the chat client. + requestTimeout = 10 * time.Second + + // Limits on clients that send or read slowly. writeTimeout outlasts + // requestTimeout, so a handler that ran out of time can still + // answer. + readHeaderTimeout = 5 * time.Second + readTimeout = 10 * time.Second + writeTimeout = requestTimeout + 5*time.Second + idleTimeout = 60 * time.Second +) + +// ChatClient is the part of the chat client the API uses. +// *simplex.Client provides it. +type ChatClient interface { + // Contacts returns the contacts of the user userID. + Contacts(ctx context.Context, userID int64) ([]simplex.Contact, error) + + // ChatItems returns the last count items of the chat with a + // contact, oldest first. + ChatItems( + ctx context.Context, contactID int64, count int, + ) ([]simplex.ChatItem, error) + + // SendMessage sends a text message to a contact and returns it. + SendMessage( + ctx context.Context, contactID int64, text string, + ) (simplex.ChatItem, error) +} + +// Params configures New. +type Params struct { + Log *slog.Logger + + // Client is the chat client, and UserID the bot's user profile in + // it. + Client ChatClient + UserID int64 + + // Port is the TCP port to listen on, on all interfaces. + Port int + + // Token is the credential every request must carry. Empty refuses + // every request. + Token string + + // Webhooks holds the webhooks registered on the chats; ReadWebhooks + // makes it. + Webhooks *Webhooks +} + +// New returns the API's server. The caller starts it with +// ListenAndServe and stops it with Shutdown. +func New(p Params) *http.Server { + if p.Token == "" { + p.Log.Warn("API_TOKEN_FILE is not set, so the API refuses every request") + } + + h := &handlers{ + log: p.Log, + client: p.Client, + userID: p.UserID, + token: p.Token, + webhooks: p.Webhooks, + } + + router := chi.NewRouter() + router.Use(securityHeaders, h.authenticate, + middleware.RequestSize(maxBodyBytes), withTimeout) + router.NotFound(func(w http.ResponseWriter, _ *http.Request) { + h.respondError(w, http.StatusNotFound, "not found") + }) + router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { + h.respondError(w, http.StatusMethodNotAllowed, "method not allowed") + }) + router.Route("/api/v1", func(r chi.Router) { + r.Get("/chats", h.handleChats()) + r.Get("/chats/{id}/messages", h.handleMessages()) + r.Post("/chats/{id}/messages", h.handleSend()) + r.Get("/chats/{id}/webhooks", h.handleWebhooks()) + r.Post("/chats/{id}/webhooks", h.handleRegister()) + r.Delete("/chats/{id}/webhooks/{webhook_id}", h.handleRemove()) + }) + + return &http.Server{ + Addr: ":" + strconv.Itoa(p.Port), + Handler: router, + ReadHeaderTimeout: readHeaderTimeout, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + // Otherwise net/http answers "OPTIONS *" itself, with 200 and + // without the credential check or the headers. + DisableGeneralOptionsHandler: true, + // net/http's own messages, such as a handler's panic, go to the + // same JSON log as everything else. + ErrorLog: slog.NewLogLogger(p.Log.Handler(), slog.LevelError), + } +} + +// handlers holds what the handlers share. +type handlers struct { + log *slog.Logger + client ChatClient + userID int64 + token string + webhooks *Webhooks +} + +// authenticate lets a request through only if it carries the +// credential. With no credential it refuses everything, and must: +// ConstantTimeCompare finds two empty strings equal, so an empty bearer +// would get in. +func (h *handlers) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + scheme, credential, _ := strings.Cut(r.Header.Get("Authorization"), " ") + + if h.token == "" || !strings.EqualFold(scheme, "Bearer") || + subtle.ConstantTimeCompare([]byte(credential), []byte(h.token)) != 1 { + w.Header().Set("WWW-Authenticate", "Bearer") + h.respondError(w, http.StatusUnauthorized, "unauthorized") + + return + } + + next.ServeHTTP(w, r) + }) +} + +// respond sends v as the JSON body of a response with status. +func (h *handlers) respond(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + err := json.NewEncoder(w).Encode(v) + if err != nil { + h.log.Warn("sending a response", "error", err) + } +} + +// respondError sends status with a chosen sentence. An error's own text +// never goes to the client, since it can describe the machine; it goes +// to the log. +func (h *handlers) respondError(w http.ResponseWriter, status int, sentence string) { + h.respond(w, status, struct { + Error string `json:"error"` + }{sentence}) +} + +// decodeBody decodes the request's JSON body into v. If the body is too +// large, or is not JSON of v's shape, it answers the request itself, 413 +// or 400 naming example as the shape wanted, and returns false. +func (h *handlers) decodeBody( + w http.ResponseWriter, r *http.Request, v any, example string, +) bool { + body, err := io.ReadAll(r.Body) + + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + h.respondError(w, http.StatusRequestEntityTooLarge, "the body is too large") + + return false + } + + if err != nil || json.Unmarshal(body, v) != nil { + h.respondError(w, http.StatusBadRequest, "the body must be JSON such as "+example) + + return false + } + + return true +} + +// securityHeaders go on every response. The API returns JSON to +// programs, so a browser may not frame, sniff, cache or refer from it, +// nor give it the camera, microphone or location, and must reach it +// over HTTPS. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := w.Header() + header.Set("X-Content-Type-Options", "nosniff") + header.Set("Content-Security-Policy", + "default-src 'none'; frame-ancestors 'none'") + header.Set("X-Frame-Options", "DENY") + header.Set("Referrer-Policy", "no-referrer") + header.Set("Permissions-Policy", + "camera=(), microphone=(), geolocation=()") + header.Set("Strict-Transport-Security", + "max-age=31536000; includeSubDomains") + header.Set("Cache-Control", "no-store") + + next.ServeHTTP(w, r) + }) +} + +// withTimeout ends each request's context after requestTimeout, so a +// handler waiting on the chat client gives up and answers. +func withTimeout(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) + defer cancel() + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..97cebd9 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,296 @@ +package api_test + +import ( + "bytes" + "context" + "errors" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "sneak.berlin/go/simplexcalc/internal/api" + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +const ( + // credential is what the API under test is configured with. + credential = "a-credential-for-these-tests" //nolint:gosec // G101: invented for tests + bearer = "Bearer " + credential + + chatsPath = "/api/v1/chats" + messagesPath = "/api/v1/chats/3/messages" + webhooksPath = "/api/v1/chats/3/webhooks" + unauthorized = `{"error":"unauthorized"}` + "\n" +) + +var errChat = errors.New("sqlite: database is locked at /var/lib/simplexcalc") + +// fakeClient stands in for the chat client. It answers with contacts, +// items or sent, or with an error: contactsErr from Contacts, err from +// the others. It remembers what it was asked. +type fakeClient struct { + contacts []simplex.Contact + items []simplex.ChatItem + sent simplex.ChatItem + contactsErr error + err error + + userID, contactID int64 + count int + text string + hadDeadline bool +} + +func (f *fakeClient) Contacts( + ctx context.Context, userID int64, +) ([]simplex.Contact, error) { + f.userID = userID + _, f.hadDeadline = ctx.Deadline() + + return f.contacts, f.contactsErr +} + +func (f *fakeClient) ChatItems( + ctx context.Context, contactID int64, count int, +) ([]simplex.ChatItem, error) { + f.contactID, f.count = contactID, count + _, f.hadDeadline = ctx.Deadline() + + return f.items, f.err +} + +func (f *fakeClient) SendMessage( + ctx context.Context, contactID int64, text string, +) (simplex.ChatItem, error) { + f.contactID, f.text = contactID, text + _, f.hadDeadline = ctx.Deadline() + + return f.sent, f.err +} + +func newAPI(token string, client api.ChatClient) *http.Server { + return api.New(api.Params{ + Log: slog.New(slog.DiscardHandler), + Client: client, + UserID: 1, + Port: 8080, + Token: token, + }) +} + +// request sends srv one request, with the Authorization header auth +// unless that is empty. +func request( + t *testing.T, srv *http.Server, method, path, auth string, +) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequestWithContext(t.Context(), method, path, nil) + if auth != "" { + req.Header.Set("Authorization", auth) + } + + rec := httptest.NewRecorder() + srv.Handler.ServeHTTP(rec, req) + + return rec +} + +// TestCredential: only the configured credential, sent as a bearer, +// gets in. With none configured, nothing does, an empty bearer +// included. +func TestCredential(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + token, auth string + in bool + }{ + "right": {credential, bearer, true}, + "right, scheme in lower case": {credential, "bearer " + credential, true}, + "wrong": {credential, strings.ToUpper(bearer), false}, + "missing": {credential, "", false}, + "another scheme": {credential, "Basic " + credential, false}, + "no scheme": {credential, credential, false}, + "empty bearer": {credential, "Bearer ", false}, + "none configured": {"", bearer, false}, + "none configured, empty bearer": {"", "Bearer ", false}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + rec := request(t, newAPI(tc.token, &fakeClient{}), + http.MethodGet, chatsPath, tc.auth) + + if tc.in { + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + + return + } + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + + if got := rec.Header().Get("WWW-Authenticate"); got != "Bearer" { + t.Errorf("WWW-Authenticate = %q, want Bearer", got) + } + + if rec.Body.String() != unauthorized { + t.Errorf("body = %q, want %q", rec.Body.String(), unauthorized) + } + }) + } +} + +// TestNoCredentialWarns: an API without a credential says at startup +// that it refuses every request. +func TestNoCredentialWarns(t *testing.T) { + t.Parallel() + + var logged bytes.Buffer + + api.New(api.Params{ + Log: slog.New(slog.NewJSONHandler(&logged, nil)), + Client: &fakeClient{}, + Port: 8080, + }) + + if !strings.Contains(logged.String(), `"level":"WARN"`) || + !strings.Contains(logged.String(), "API_TOKEN_FILE is not set") { + t.Errorf("log = %q, want a warning that API_TOKEN_FILE is not set", + logged.String()) + } +} + +// TestNoPathIsExempt: an unknown path or method needs the credential +// like everything else, and then gets a JSON error. +func TestNoPathIsExempt(t *testing.T) { + t.Parallel() + + srv := newAPI(credential, &fakeClient{}) + + for _, tc := range []struct { + method, path, auth string + want int + body string + }{ + {http.MethodGet, "/", "", http.StatusUnauthorized, unauthorized}, + { + http.MethodGet, "/.well-known/healthcheck", "", + http.StatusUnauthorized, unauthorized, + }, + { + http.MethodGet, "/api/v1/nothing", bearer, + http.StatusNotFound, `{"error":"not found"}` + "\n", + }, + { + http.MethodPost, chatsPath, bearer, + http.StatusMethodNotAllowed, `{"error":"method not allowed"}` + "\n", + }, + { + http.MethodPost, messagesPath, "", + http.StatusUnauthorized, unauthorized, + }, + {http.MethodGet, webhooksPath, "", http.StatusUnauthorized, unauthorized}, + {http.MethodPost, webhooksPath, "", http.StatusUnauthorized, unauthorized}, + { + http.MethodDelete, webhooksPath + "/" + unknownID, "", + http.StatusUnauthorized, unauthorized, + }, + } { + rec := request(t, srv, tc.method, tc.path, tc.auth) + if rec.Code != tc.want || rec.Body.String() != tc.body { + t.Errorf("%s %s: %d %q, want %d %q", tc.method, tc.path, + rec.Code, rec.Body.String(), tc.want, tc.body) + } + } +} + +// TestOptionsAsterisk: "OPTIONS *" is refused like any other request. +// net/http would answer it before the handler, so this request goes to +// a running server rather than to its handler. +func TestOptionsAsterisk(t *testing.T) { + t.Parallel() + + srv := newAPI("", &fakeClient{}) + + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + go func() { _ = srv.Serve(listener) }() + + t.Cleanup(func() { _ = srv.Close() }) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodOptions, + "http://"+listener.Addr().String(), nil) + if err != nil { + t.Fatal(err) + } + + // The request line becomes "OPTIONS * HTTP/1.1". + req.URL.Opaque = "*" + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + + if resp.StatusCode != http.StatusUnauthorized || string(body) != unauthorized { + t.Errorf("OPTIONS *: %d %q, want 401 %q", resp.StatusCode, body, unauthorized) + } +} + +// TestHeaders: every response, whatever its status, carries the +// security headers, and none lets another origin in. +func TestHeaders(t *testing.T) { + t.Parallel() + + want := map[string]string{ + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "Cache-Control": "no-store", + } + + for _, tc := range []struct { + client *fakeClient + path, auth string + }{ + {&fakeClient{}, chatsPath, bearer}, + {&fakeClient{}, chatsPath, ""}, + {&fakeClient{}, "/nothing", bearer}, + {&fakeClient{contactsErr: errChat}, chatsPath, bearer}, + } { + rec := request(t, newAPI(credential, tc.client), + http.MethodGet, tc.path, tc.auth) + + for key, value := range want { + if got := rec.Header().Get(key); got != value { + t.Errorf("%d response: %s = %q, want %q", rec.Code, key, got, value) + } + } + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("%d response: Access-Control-Allow-Origin = %q", rec.Code, got) + } + } +} diff --git a/internal/api/chats.go b/internal/api/chats.go new file mode 100644 index 0000000..9b8bce7 --- /dev/null +++ b/internal/api/chats.go @@ -0,0 +1,97 @@ +package api + +import ( + "cmp" + "context" + "net/http" + "slices" + "strconv" + + "github.com/go-chi/chi/v5" +) + +// noSuchChat answers a request naming a chat the bot does not have. +const noSuchChat = "no such chat" + +// chat is how the API shows a chat. The bot talks to people only one to +// one, so its chats are its contacts, and a chat's id is its contact's. +type chat struct { + ID int64 `json:"id"` + DisplayName string `json:"display_name"` + ContactDeleted bool `json:"contact_deleted"` +} + +// chats returns the bot's chats, ordered by id. GET /api/v1/chats +// answers with this list, and chatID accepts only an id in it. +func (h *handlers) chats(ctx context.Context) ([]chat, error) { + contacts, err := h.client.Contacts(ctx, h.userID) + if err != nil { + return nil, err + } + + chats := make([]chat, 0, len(contacts)) + for _, c := range contacts { + chats = append(chats, chat{ + ID: c.ContactID, + DisplayName: c.Profile.DisplayName, + ContactDeleted: c.Deleted(), + }) + } + + slices.SortFunc(chats, func(a, b chat) int { return cmp.Compare(a.ID, b.ID) }) + + return chats, nil +} + +// handleChats lists the bot's chats. +func (h *handlers) handleChats() http.HandlerFunc { + type response struct { + Chats []chat `json:"chats"` + } + + return func(w http.ResponseWriter, r *http.Request) { + chats, err := h.chats(r.Context()) + if err != nil { + h.log.Error("listing the chats", "error", err) + h.respondError(w, http.StatusInternalServerError, + "the chats could not be read") + + return + } + + h.respond(w, http.StatusOK, response{Chats: chats}) + } +} + +// chatID returns the chat id in the request's path, if it is one of the +// bot's chats. Otherwise it answers the request itself, 404, or 500 if +// the chats could not be read, and returns false. +// +// The id is looked up rather than passed to the chat client, which also +// keeps contact records that are not chats, such as the bot's own +// profile, and would read or send in them. +func (h *handlers) chatID(w http.ResponseWriter, r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + h.respondError(w, http.StatusNotFound, noSuchChat) + + return 0, false + } + + chats, err := h.chats(r.Context()) + if err != nil { + h.log.Error("looking up a chat", "error", err) + h.respondError(w, http.StatusInternalServerError, + "the chats could not be read") + + return 0, false + } + + if !slices.ContainsFunc(chats, func(c chat) bool { return c.ID == id }) { + h.respondError(w, http.StatusNotFound, noSuchChat) + + return 0, false + } + + return id, true +} diff --git a/internal/api/chats_test.go b/internal/api/chats_test.go new file mode 100644 index 0000000..14f0c00 --- /dev/null +++ b/internal/api/chats_test.go @@ -0,0 +1,64 @@ +package api_test + +import ( + "net/http" + "testing" + + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +// TestChats: the chats are the bot's contacts, ordered by id, deleted +// ones marked, and the chat client is asked for the bot's user with a +// deadline. +func TestChats(t *testing.T) { + t.Parallel() + + client := &fakeClient{contacts: []simplex.Contact{ + {ContactID: 3, Profile: simplex.Profile{DisplayName: "bob"}, Status: "deleted"}, + {ContactID: 2, Profile: simplex.Profile{DisplayName: "alice"}, Status: "active"}, + }} + + rec := request(t, newAPI(credential, client), http.MethodGet, chatsPath, bearer) + + want := `{"chats":[{"id":2,"display_name":"alice","contact_deleted":false},` + + `{"id":3,"display_name":"bob","contact_deleted":true}]}` + "\n" + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want) + } + + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + + if client.userID != 1 || !client.hadDeadline { + t.Errorf("the chat client was asked for user %d, deadline %v; "+ + "want user 1 with a deadline", client.userID, client.hadDeadline) + } +} + +// TestNoChats: no contacts is an empty list, not null. +func TestNoChats(t *testing.T) { + t.Parallel() + + rec := request(t, newAPI(credential, &fakeClient{}), + http.MethodGet, chatsPath, bearer) + + want := `{"chats":[]}` + "\n" + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want) + } +} + +// TestChatsFailure: when the chat client fails, the response says so +// in a chosen sentence, never in the error's own text. +func TestChatsFailure(t *testing.T) { + t.Parallel() + + rec := request(t, newAPI(credential, &fakeClient{contactsErr: errChat}), + http.MethodGet, chatsPath, bearer) + + want := `{"error":"the chats could not be read"}` + "\n" + if rec.Code != http.StatusInternalServerError || rec.Body.String() != want { + t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want) + } +} diff --git a/internal/api/deliveries.go b/internal/api/deliveries.go new file mode 100644 index 0000000..a037b54 --- /dev/null +++ b/internal/api/deliveries.go @@ -0,0 +1,187 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/url" + "sync" + "time" + + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +const ( + // queueLength is how many messages can wait to be posted, and + // deliveryGoroutines how many goroutines post them, each one message + // at a time. + queueLength = 100 + deliveryGoroutines = 4 + + // deliveryTimeout bounds one POST to a webhook. + deliveryTimeout = 10 * time.Second +) + +// delivery is the body of the POST to each webhook of a chat: a message a +// contact sent in it, as GET /api/v1/chats/{id}/messages shows it. +type delivery struct { + ChatID int64 `json:"chat_id"` + Message message `json:"message"` +} + +// Deliveries posts each message a contact sends to each webhook of its +// chat, once, with no retry. Add hands it a message without waiting: the +// message joins a queue served by deliveryGoroutines goroutines, or is +// dropped if the queue is full. What goes wrong is logged with ids only: +// never a webhook's URL, which can hold a secret, nor a message's text. +type Deliveries struct { + log *slog.Logger + webhooks *Webhooks + client *http.Client + queue chan delivery + + // stop ends the goroutines' context, abandoning the POSTs in + // progress; wg waits for the goroutines to return. + stop context.CancelFunc + wg sync.WaitGroup +} + +// StartDeliveries starts posting the messages Add is given, until ctx +// ends or Stop is called. +func StartDeliveries( + ctx context.Context, log *slog.Logger, webhooks *Webhooks, +) *Deliveries { + ctx, stop := context.WithCancel(ctx) + + d := &Deliveries{ + log: log, + webhooks: webhooks, + client: &http.Client{ + Timeout: deliveryTimeout, + // A redirect is not followed, and so counts as a status + // other than 2xx: following it could turn the POST into a + // GET without the message. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + queue: make(chan delivery, queueLength), + stop: stop, + } + + for range deliveryGoroutines { + d.wg.Go(func() { d.serve(ctx) }) + } + + return d +} + +// Add queues the message in item for the webhooks of its chat, if it is a +// message a contact sent in a direct chat: one that GET +// /api/v1/chats/{id}/messages shows as received. The chat client's event +// handler calls it, so it never waits: if the queue is full, the message +// is dropped, and the log says so. +func (d *Deliveries) Add(item simplex.AChatItem) { + contact := item.ChatInfo.Contact + + m, ok := newMessage(item.ChatItem) + if !ok || m.Direction != "received" || item.ChatInfo.Type != "direct" || + contact == nil { + return + } + + select { + case d.queue <- delivery{ChatID: contact.ContactID, Message: m}: + default: + d.log.Warn("dropping a message for the webhooks: the queue is full", + "chat_id", contact.ContactID, "message_id", m.ID) + } +} + +// Stop stops posting, and returns once the goroutines have: POSTs in +// progress are abandoned, and messages still queued are not posted. +func (d *Deliveries) Stop() { + d.stop() + d.wg.Wait() +} + +// serve posts queued messages, one at a time, until ctx ends. +func (d *Deliveries) serve(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case m := <-d.queue: + d.deliver(ctx, m) + } + } +} + +// deliver posts m to each webhook of its chat, and logs each POST that +// fails or is answered with a status other than 2xx. The webhooks are +// read here rather than in Add, as reading them waits while the webhooks +// file is written. +func (d *Deliveries) deliver(ctx context.Context, m delivery) { + body, err := json.Marshal(m) + if err != nil { + d.log.Error("encoding a message for the webhooks", + "message_id", m.Message.ID, "error", err) + + return + } + + for _, hook := range d.webhooks.list(m.ChatID) { + // Stopped: the chat's other webhooks do not get the message. + if ctx.Err() != nil { + return + } + + status, err := d.post(ctx, hook.URL, body) + + switch { + case err != nil: + d.log.Warn("posting a message to a webhook", + "webhook_id", hook.ID, "message_id", m.Message.ID, "error", err) + case status < http.StatusOK || status >= http.StatusMultipleChoices: + d.log.Warn("posting a message to a webhook", + "webhook_id", hook.ID, "message_id", m.Message.ID, "status", status) + } + } +} + +// post sends body to hookURL, once, and returns the status of the answer. +// Its error, unlike net/http's, never holds the URL. +func (d *Deliveries) post( + ctx context.Context, hookURL string, body []byte, +) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, hookURL, + bytes.NewReader(body)) + if err != nil { + return 0, withoutURL(err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := d.client.Do(req) + if err != nil { + return 0, withoutURL(err) + } + + _ = resp.Body.Close() + + return resp.StatusCode, nil +} + +// withoutURL returns the cause of a *url.Error, whose own text holds the +// URL, and any other error as it is. +func withoutURL(err error) error { + var urlErr *url.Error + if errors.As(err, &urlErr) { + return urlErr.Err + } + + return err +} diff --git a/internal/api/deliveries_test.go b/internal/api/deliveries_test.go new file mode 100644 index 0000000..1a35513 --- /dev/null +++ b/internal/api/deliveries_test.go @@ -0,0 +1,328 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "sneak.berlin/go/simplexcalc/internal/api" + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +// newChatItems is a newChatItems event, reduced to the fields the bot +// reads. It holds, in this order, a message in a group, a message the bot +// sent, an event the chat client records in a chat, and two messages a +// contact sent: a text and a picture with a caption. +const newChatItems = `{"type":"newChatItems","chatItems":[ +{"chatInfo":{"type":"group","groupInfo":{"groupId":9}}, + "chatItem":{"chatDir":{"type":"groupRcv"},"meta":{"itemId":5}, + "content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"1 + 1"}}}}, +{"chatInfo":{"type":"direct","contact":{"contactId":3}}, + "chatItem":{"chatDir":{"type":"directSnd"},"meta":{"itemId":6}, + "content":{"type":"sndMsgContent","msgContent":{"type":"text","text":"2"}}}}, +{"chatInfo":{"type":"direct","contact":{"contactId":3}}, + "chatItem":{"chatDir":{"type":"directRcv"},"meta":{"itemId":7}, + "content":{"type":"rcvChatFeature","feature":"calls"}}}, +{"chatInfo":{"type":"direct","contact":{"contactId":3}}, + "chatItem":{"chatDir":{"type":"directRcv"}, + "meta":{"itemId":9,"itemTs":"2026-09-29T03:14:34Z"}, + "content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"2 + 2"}}}}, +{"chatInfo":{"type":"direct","contact":{"contactId":3}}, + "chatItem":{"chatDir":{"type":"directRcv"}, + "meta":{"itemId":11,"itemTs":"2026-09-29T03:15:02Z"}, + "content":{"type":"rcvMsgContent","msgContent":{"type":"image", + "text":"a picture","image":"data:image/jpg;base64,/9j/4AAQ"}}}}]}` + +// textItem is the index in newChatItems of the text the contact sent. +const textItem = 3 + +// logBuffer is a log that the goroutines posting messages write to while +// the test reads it. +type logBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *logBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + return b.buf.Write(p) +} + +func (b *logBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + + return b.buf.String() +} + +// chatItems returns the chat items in a newChatItems record. +func chatItems(t *testing.T, record string) []simplex.AChatItem { + t.Helper() + + var r simplex.NewChatItems + + err := json.Unmarshal([]byte(record), &r) + if err != nil { + t.Fatalf("decoding %s: %v", record, err) + } + + return r.ChatItems +} + +// receive returns the next of posts, and fails the test if none comes +// within 5 seconds. +func receive(t *testing.T, posts <-chan string) string { + t.Helper() + + select { + case p := <-posts: + return p + case <-time.After(5 * time.Second): + t.Fatal("nothing more was posted") + + return "" + } +} + +// within runs f, and fails the test with complaint if f has not returned +// within 5 seconds. +func within(t *testing.T, complaint string, f func()) { + t.Helper() + + done := make(chan struct{}) + + go func() { + defer close(done) + + f() + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal(complaint) + } +} + +// TestDeliveries: each message a contact sends is posted, as JSON with +// its chat's id, to each webhook of its chat and to no other chat's. +// Messages in groups, the bot's own messages and the events the chat +// client records in a chat are not posted. +func TestDeliveries(t *testing.T) { + t.Parallel() + + posts := make(chan string, 10) + receiver := httptest.NewServer(http.HandlerFunc( + func(_ http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + posts <- r.Method + " " + r.URL.Path + " " + + r.Header.Get("Content-Type") + " " + string(body) + })) + t.Cleanup(receiver.Close) + + webhooks := readWebhooks(t, t.TempDir()) + srv := webhookAPI(webhooks) + + // Chat 4's webhook comes between chat 3's, so a message posted to + // every webhook in turn would reach it before chat 3's second. + register(t, srv, webhooksPath, receiver.URL+"/a", http.StatusCreated) + register(t, srv, "/api/v1/chats/4/webhooks", receiver.URL+"/c", http.StatusCreated) + register(t, srv, webhooksPath, receiver.URL+"/b", http.StatusCreated) + + deliveries := api.StartDeliveries(t.Context(), slog.New(slog.DiscardHandler), + webhooks) + t.Cleanup(deliveries.Stop) + + for _, item := range chatItems(t, newChatItems) { + deliveries.Add(item) + } + + text := `{"chat_id":3,"message":{"id":9,"direction":"received",` + + `"type":"text","text":"2 + 2","time":"2026-09-29T03:14:34Z"}}` + picture := `{"chat_id":3,"message":{"id":11,"direction":"received",` + + `"type":"image","text":"a picture","time":"2026-09-29T03:15:02Z"}}` + want := []string{ + "POST /a application/json " + text, + "POST /a application/json " + picture, + "POST /b application/json " + text, + "POST /b application/json " + picture, + } + got := make([]string, 0, len(want)+1) + + for range want { + got = append(got, receive(t, posts)) + } + + deliveries.Stop() + + select { + case p := <-posts: + got = append(got, p) + default: + } + + slices.Sort(want) + slices.Sort(got) + + if !slices.Equal(got, want) { + t.Errorf("posted:\n%s\nwant:\n%s", + strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// TestDeliveriesQueueFull: while a webhook holds a POST from each of the +// goroutines, Add queues as many messages as the queue takes, then drops +// the next rather than wait, logging it by its chat's id and its own, +// never its text. Stop abandons the POSTs in progress rather than wait +// for them. +func TestDeliveriesQueueFull(t *testing.T) { + t.Parallel() + + held := make(chan string, api.DeliveryGoroutines) + receiver := httptest.NewServer(http.HandlerFunc( + func(_ http.ResponseWriter, r *http.Request) { + // Read to the end: only then does the server notice when the + // bot abandons the POST, and end r's context. + body, _ := io.ReadAll(r.Body) + held <- string(body) + + <-r.Context().Done() // never answers + })) + t.Cleanup(receiver.Close) + + webhooks := readWebhooks(t, t.TempDir()) + register(t, webhookAPI(webhooks), webhooksPath, receiver.URL, http.StatusCreated) + + var logged logBuffer + + deliveries := api.StartDeliveries(t.Context(), + slog.New(slog.NewJSONHandler(&logged, nil)), webhooks) + t.Cleanup(deliveries.Stop) + + // A message for each goroutine, whose POST the webhook holds, then as + // many as the queue takes, then one more. + items := make([]simplex.AChatItem, api.DeliveryGoroutines+api.QueueLength+1) + for i := range items { + items[i] = chatItems(t, newChatItems)[textItem] + items[i].ChatItem.Meta.ItemID = int64(i + 1) + } + + for _, item := range items[:api.DeliveryGoroutines] { + deliveries.Add(item) + receive(t, held) + } + + within(t, "Add waited for room in the queue", func() { + for _, item := range items[api.DeliveryGoroutines:] { + deliveries.Add(item) + } + }) + + within(t, "Stop waited for the webhook to answer", deliveries.Stop) + + log := logged.String() + dropped := `"level":"WARN","msg":"dropping a message for the webhooks: ` + + `the queue is full","chat_id":3,"message_id":` + + if strings.Count(log, dropped) != 1 || + !strings.Contains(log, dropped+strconv.Itoa(len(items))+"}") { + t.Errorf("log = %s, want message %d dropped, and no other", log, len(items)) + } + + if strings.Contains(log, "2 + 2") { + t.Errorf("log = %s, holding a message's text", log) + } +} + +// TestDeliveryFailures: a POST that fails, or is answered with a status +// other than 2xx, is logged with the webhook's id, the message's and the +// reason or the status, and is not made again. A redirect is not +// followed. The log holds neither the webhook's URL nor the message's +// text. +func TestDeliveryFailures(t *testing.T) { + t.Parallel() + + requested := make(chan string, 10) + receiver := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + requested <- r.URL.Path + + switch r.URL.Path { + case "/refuses": + w.WriteHeader(http.StatusServiceUnavailable) + case "/redirects": + http.Redirect(w, r, "/elsewhere", http.StatusFound) + case "/hangs-up": + conn, _, err := http.NewResponseController(w).Hijack() + if err == nil { + _ = conn.Close() + } + } + })) + t.Cleanup(receiver.Close) + + webhooks := readWebhooks(t, t.TempDir()) + paths := []string{"/hangs-up", "/redirects", "/refuses"} + ids := map[string]string{} + + // The query stands for a secret that a webhook's URL can hold. + for _, path := range paths { + ids[path] = register(t, webhookAPI(webhooks), webhooksPath, + receiver.URL+path+"?key=s3cret", http.StatusCreated).ID + } + + var logged logBuffer + + deliveries := api.StartDeliveries(t.Context(), + slog.New(slog.NewJSONHandler(&logged, nil)), webhooks) + t.Cleanup(deliveries.Stop) + + deliveries.Add(chatItems(t, newChatItems)[textItem]) + + failed := `"level":"WARN","msg":"posting a message to a webhook","webhook_id":"` + + within(t, "the three failures were not logged", func() { + for strings.Count(logged.String(), failed) < len(paths) { + time.Sleep(10 * time.Millisecond) + } + }) + + log := logged.String() + + for _, want := range []string{ + failed + ids["/hangs-up"] + `","message_id":9,"error":"`, + failed + ids["/redirects"] + `","message_id":9,"status":302}`, + failed + ids["/refuses"] + `","message_id":9,"status":503}`, + } { + if !strings.Contains(log, want) { + t.Errorf("log = %s, want it to hold %s", log, want) + } + } + + for _, secret := range []string{receiver.URL, "s3cret", "2 + 2"} { + if strings.Contains(log, secret) { + t.Errorf("log = %s, holding %s", log, secret) + } + } + + // Each webhook was asked once, before its failure was logged, and + // the redirect's target never. + got := []string{<-requested, <-requested, <-requested} + slices.Sort(got) + + if !slices.Equal(got, paths) || len(requested) != 0 { + t.Errorf("requested %v and %d more, want %v once each", + got, len(requested), paths) + } +} diff --git a/internal/api/export_test.go b/internal/api/export_test.go new file mode 100644 index 0000000..d718127 --- /dev/null +++ b/internal/api/export_test.go @@ -0,0 +1,9 @@ +package api + +// DeliveryGoroutines and QueueLength are deliveryGoroutines and +// queueLength, exported for the external test package, which fills the +// queue. +const ( + DeliveryGoroutines = deliveryGoroutines + QueueLength = queueLength +) diff --git a/internal/api/messages.go b/internal/api/messages.go new file mode 100644 index 0000000..19547b9 --- /dev/null +++ b/internal/api/messages.go @@ -0,0 +1,188 @@ +package api + +import ( + "errors" + "net/http" + "net/url" + "strconv" + "time" + + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +const ( + // defaultCount and maxCount bound how many of a chat's last items a + // request for its messages reads. + defaultCount = 20 + maxCount = 100 +) + +// message is how the API shows a message, wherever it shows one. +type message struct { + ID int64 `json:"id"` + Direction string `json:"direction"` + Type string `json:"type"` + Text string `json:"text"` + Time time.Time `json:"time"` +} + +// newMessage returns the message a chat item holds, and false for an +// item that holds none: the chat client also records events in a chat, +// such as the contact connecting. +func newMessage(item simplex.ChatItem) (message, bool) { + var direction string + + switch item.Content.Type { + case "rcvMsgContent": + direction = "received" + case "sndMsgContent": + direction = "sent" + default: + return message{}, false + } + + content := item.Content.MsgContent + if content == nil { + return message{}, false + } + + return message{ + ID: item.Meta.ItemID, + Direction: direction, + Type: content.Type, + Text: content.Text, + Time: item.Meta.ItemTs, + }, true +} + +// handleMessages answers with the messages among a chat's last count +// items, oldest first. +func (h *handlers) handleMessages() http.HandlerFunc { + type response struct { + Messages []message `json:"messages"` + } + + return func(w http.ResponseWriter, r *http.Request) { + contactID, ok := h.chatID(w, r) + if !ok { + return + } + + // Not r.URL.Query, which drops a pair it cannot decode and so + // would turn count=1% into the default. + query, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + h.respondError(w, http.StatusBadRequest, "the query cannot be read") + + return + } + + count, ok := messageCount(query) + if !ok { + h.respondError(w, http.StatusBadRequest, + "count must be a whole number from 1 to "+strconv.Itoa(maxCount)) + + return + } + + items, err := h.client.ChatItems(r.Context(), contactID, count) + if err != nil { + h.respondChatError(w, err, "reading a chat's messages", + "the messages could not be read") + + return + } + + messages := make([]message, 0, len(items)) + + for _, item := range items { + if m, ok := newMessage(item); ok { + messages = append(messages, m) + } + } + + h.respond(w, http.StatusOK, response{Messages: messages}) + } +} + +// handleSend sends the text in the request's body to a chat, and answers +// with the message sent. +func (h *handlers) handleSend() http.HandlerFunc { + type request struct { + Text string `json:"text"` + } + + type response struct { + Message message `json:"message"` + } + + return func(w http.ResponseWriter, r *http.Request) { + contactID, ok := h.chatID(w, r) + if !ok { + return + } + + var req request + if !h.decodeBody(w, r, &req, `{"text":"hello"}`) { + return + } + + if req.Text == "" { + h.respondError(w, http.StatusBadRequest, "text is empty") + + return + } + + item, err := h.client.SendMessage(r.Context(), contactID, req.Text) + if err != nil { + h.respondChatError(w, err, "sending a message", + "the message could not be sent") + + return + } + + sent, ok := newMessage(item) + if !ok { + h.log.Error("sending a message: the answer holds no message", + "content_type", item.Content.Type) + h.respondError(w, http.StatusInternalServerError, + "the chat client's answer could not be read") + + return + } + + h.respond(w, http.StatusCreated, response{Message: sent}) + } +} + +// respondChatError answers a request the chat client did not serve: 404 +// for a contact removed after chatID found it, 409 for one who cannot +// receive messages, 413 for a text too long to send, and 500 with +// sentence for anything else, which is logged as what. +func (h *handlers) respondChatError( + w http.ResponseWriter, err error, what, sentence string, +) { + switch { + case errors.Is(err, simplex.ErrNoContact): + h.respondError(w, http.StatusNotFound, noSuchChat) + case errors.Is(err, simplex.ErrContactNotReady): + h.respondError(w, http.StatusConflict, "the contact cannot receive messages") + case errors.Is(err, simplex.ErrMessageTooLarge): + h.respondError(w, http.StatusRequestEntityTooLarge, "the text is too long") + default: + h.log.Error(what, "error", err) + h.respondError(w, http.StatusInternalServerError, sentence) + } +} + +// messageCount returns the query's count, defaultCount if it has none, +// and false if it is anything but a whole number from 1 to maxCount. +func messageCount(query url.Values) (int, bool) { + if !query.Has("count") { + return defaultCount, true + } + + count, err := strconv.Atoi(query.Get("count")) + + return count, err == nil && count >= 1 && count <= maxCount +} diff --git a/internal/api/messages_test.go b/internal/api/messages_test.go new file mode 100644 index 0000000..24d1de7 --- /dev/null +++ b/internal/api/messages_test.go @@ -0,0 +1,367 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +// Answers more than one test expects. +const ( + noSuchChat = `{"error":"no such chat"}` + "\n" + badCount = `{"error":"count must be a whole number from 1 to 100"}` + "\n" + notJSON = `{"error":"the body must be JSON such as {\"text\":\"hello\"}"}` + "\n" +) + +// oneChat returns the bot's contacts in these tests: one, whose chat is +// the one messagesPath names. +func oneChat() []simplex.Contact { + return []simplex.Contact{{ContactID: 3, Status: "active"}} +} + +// chatItem is a chat item decoded from a record shaped as the chat +// client sends it, reduced to the fields the API reads. +func chatItem(t *testing.T, record string) simplex.ChatItem { + t.Helper() + + var item simplex.ChatItem + + err := json.Unmarshal([]byte(record), &item) + if err != nil { + t.Fatalf("decoding %s: %v", record, err) + } + + return item +} + +// post sends srv a POST of body to path, with the credential. +func post( + t *testing.T, srv *http.Server, path, body string, +) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, path, + strings.NewReader(body)) + req.Header.Set("Authorization", bearer) + + rec := httptest.NewRecorder() + srv.Handler.ServeHTTP(rec, req) + + return rec +} + +// TestMessages: the messages among the chat's last 20 items come back +// oldest first, without the events the chat client records in a chat, +// and the chat client is asked with a deadline. +func TestMessages(t *testing.T) { + t.Parallel() + + client := &fakeClient{contacts: oneChat(), items: []simplex.ChatItem{ + chatItem(t, `{"meta":{"itemId":7,"itemTs":"2026-09-29T03:13:46.521359978Z"}, + "content":{"type":"rcvChatFeature","feature":"calls"}}`), + chatItem(t, `{"meta":{"itemId":9,"itemTs":"2026-09-29T03:14:34Z"}, + "content":{"type":"rcvMsgContent", + "msgContent":{"type":"text","text":"2 + 2"}}}`), + chatItem(t, `{"meta":{"itemId":10,"itemTs":"2026-09-29T03:14:35.101Z"}, + "content":{"type":"sndMsgContent","msgContent":{"type":"text","text":"4"}}}`), + chatItem(t, `{"meta":{"itemId":11,"itemTs":"2026-09-29T03:15:02Z"}, + "content":{"type":"rcvMsgContent","msgContent":{"type":"image", + "text":"a picture","image":"data:image/jpg;base64,/9j/4AAQ"}}}`), + }} + + rec := request(t, newAPI(credential, client), http.MethodGet, messagesPath, bearer) + + want := `{"messages":[` + + `{"id":9,"direction":"received","type":"text","text":"2 + 2",` + + `"time":"2026-09-29T03:14:34Z"},` + + `{"id":10,"direction":"sent","type":"text","text":"4",` + + `"time":"2026-09-29T03:14:35.101Z"},` + + `{"id":11,"direction":"received","type":"image","text":"a picture",` + + `"time":"2026-09-29T03:15:02Z"}]}` + "\n" + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want) + } + + if client.contactID != 3 || client.count != 20 || !client.hadDeadline { + t.Errorf("the chat client was asked for %d items of chat %d, deadline %v; "+ + "want 20 of chat 3 with a deadline", + client.count, client.contactID, client.hadDeadline) + } +} + +// TestNoMessages: a chat without messages is an empty list, not null. +func TestNoMessages(t *testing.T) { + t.Parallel() + + rec := request(t, newAPI(credential, &fakeClient{contacts: oneChat()}), + http.MethodGet, messagesPath, bearer) + + want := `{"messages":[]}` + "\n" + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want) + } +} + +// TestMessagesCount: count is a whole number from 1 to 100, and 20 when +// absent. Anything else is refused before the chat's items are read. +func TestMessagesCount(t *testing.T) { + t.Parallel() + + // The number of items the chat client is asked for; 0 for refused. + for query, want := range map[string]int{ + "": 20, + "?count=1": 1, + "?count=100": 100, + "?count=%35": 5, + "?count=0": 0, + "?count=101": 0, + "?count=-1": 0, + "?count=2.5": 0, + "?count=ten": 0, + "?count=": 0, + } { + t.Run(query, func(t *testing.T) { + t.Parallel() + + client := &fakeClient{contacts: oneChat()} + rec := request(t, newAPI(credential, client), + http.MethodGet, messagesPath+query, bearer) + + if want == 0 { + if rec.Code != http.StatusBadRequest || rec.Body.String() != badCount || + client.count != 0 { + t.Errorf("response = %d %q, asked for %d items; "+ + "want 400 %q and nothing asked", rec.Code, rec.Body.String(), + client.count, badCount) + } + + return + } + + if rec.Code != http.StatusOK || client.count != want { + t.Errorf("response = %d, asked for %d items; want 200 and %d", + rec.Code, client.count, want) + } + }) + } +} + +// TestMessagesUnreadableQuery: a query that cannot be decoded is refused +// with a sentence that says so, whether or not the bad part is count. +// The last query has 10,001 parts, more than Go's url.ParseQuery takes. +func TestMessagesUnreadableQuery(t *testing.T) { + t.Parallel() + + want := `{"error":"the query cannot be read"}` + "\n" + + for _, query := range []string{ + "?count=1%", "?count=5&x=%zz", "?x=%zz", "?count=5;x=1", + "?count=5" + strings.Repeat("&", 10000), + } { + client := &fakeClient{contacts: oneChat()} + rec := request(t, newAPI(credential, client), + http.MethodGet, messagesPath+query, bearer) + + if rec.Code != http.StatusBadRequest || rec.Body.String() != want || + client.count != 0 { + t.Errorf("%s: response = %d %q, asked for %d items; "+ + "want 400 %q and nothing asked", + query, rec.Code, rec.Body.String(), client.count, want) + } + } +} + +// TestNoSuchChat: an id that GET /api/v1/chats does not list gets 404, +// whether reading messages or sending one, and nothing is read or sent. +// That includes 1 and 2, contact records the chat client keeps on a new +// profile and would read or send in. A contact the chat client no +// longer has when asked also gets 404. +func TestNoSuchChat(t *testing.T) { + t.Parallel() + + for _, id := range []string{ + "1", "2", "4", "tester", "0", "-3", "3.5", "99999999999999999999", + } { + client := &fakeClient{contacts: oneChat()} + srv := newAPI(credential, client) + path := "/api/v1/chats/" + id + "/messages" + + for _, rec := range []*httptest.ResponseRecorder{ + request(t, srv, http.MethodGet, path, bearer), + post(t, srv, path, `{"text":"hello"}`), + } { + if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchChat { + t.Errorf("chat %q: response = %d %q, want 404 %q", + id, rec.Code, rec.Body.String(), noSuchChat) + } + } + + if client.contactID != 0 { + t.Errorf("chat %q: the chat was read or sent to", id) + } + } + + srv := newAPI(credential, &fakeClient{contacts: oneChat(), err: simplex.ErrNoContact}) + + for _, rec := range []*httptest.ResponseRecorder{ + request(t, srv, http.MethodGet, messagesPath, bearer), + post(t, srv, messagesPath, `{"text":"hello"}`), + } { + if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchChat { + t.Errorf("no contact: response = %d %q, want 404 %q", + rec.Code, rec.Body.String(), noSuchChat) + } + } +} + +// TestChatLookupFailure: when the chats cannot be read to look the id +// up, the answer is 500 and nothing is read or sent. +func TestChatLookupFailure(t *testing.T) { + t.Parallel() + + client := &fakeClient{contactsErr: errChat} + srv := newAPI(credential, client) + + want := `{"error":"the chats could not be read"}` + "\n" + + for _, rec := range []*httptest.ResponseRecorder{ + request(t, srv, http.MethodGet, messagesPath, bearer), + post(t, srv, messagesPath, `{"text":"hello"}`), + } { + if rec.Code != http.StatusInternalServerError || rec.Body.String() != want { + t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want) + } + } + + if client.contactID != 0 { + t.Error("the chat was read or sent to") + } +} + +// TestMessagesFailure: when the chat client fails, the answer says so in +// a chosen sentence, never in the error's own text. +func TestMessagesFailure(t *testing.T) { + t.Parallel() + + rec := request(t, newAPI(credential, &fakeClient{contacts: oneChat(), err: errChat}), + http.MethodGet, messagesPath, bearer) + + want := `{"error":"the messages could not be read"}` + "\n" + if rec.Code != http.StatusInternalServerError || rec.Body.String() != want { + t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want) + } +} + +// TestSend: the text goes to the chat's contact, with a deadline, and +// the answer is 201 with the message as sent. +func TestSend(t *testing.T) { + t.Parallel() + + client := &fakeClient{contacts: oneChat(), sent: chatItem(t, `{"meta":{"itemId":12, + "itemTs":"2026-09-29T03:14:43.519552587Z"},"content":{"type":"sndMsgContent", + "msgContent":{"type":"text","text":"hello"}}}`)} + + rec := post(t, newAPI(credential, client), messagesPath, `{"text":"hello"}`) + + want := `{"message":{"id":12,"direction":"sent","type":"text","text":"hello",` + + `"time":"2026-09-29T03:14:43.519552587Z"}}` + "\n" + if rec.Code != http.StatusCreated || rec.Body.String() != want { + t.Errorf("response = %d %q, want 201 %q", rec.Code, rec.Body.String(), want) + } + + if client.contactID != 3 || client.text != "hello" || !client.hadDeadline { + t.Errorf("the chat client was asked to send %q to chat %d, deadline %v; "+ + `want "hello" to chat 3 with a deadline`, + client.text, client.contactID, client.hadDeadline) + } +} + +// TestSendBadBody: a body that is not JSON with a text, or that is over +// 64 KiB, is refused before anything is sent. +func TestSendBadBody(t *testing.T) { + t.Parallel() + + textEmpty := `{"error":"text is empty"}` + "\n" + tooLarge := `{"error":"the body is too large"}` + "\n" + + for name, tc := range map[string]struct { + body string + status int + answer string + }{ + "empty": {"", http.StatusBadRequest, notJSON}, + "not JSON": {"hello", http.StatusBadRequest, notJSON}, + "not an object": {`["hello"]`, http.StatusBadRequest, notJSON}, + "text not a string": {`{"text":5}`, http.StatusBadRequest, notJSON}, + "more after it": {`{"text":"hello"} {}`, http.StatusBadRequest, notJSON}, + "no text": {`{}`, http.StatusBadRequest, textEmpty}, + "empty text": {`{"text":""}`, http.StatusBadRequest, textEmpty}, + "over 64 KiB": { + `{"text":"` + strings.Repeat("a", 64<<10) + `"}`, + http.StatusRequestEntityTooLarge, tooLarge, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + client := &fakeClient{contacts: oneChat()} + rec := post(t, newAPI(credential, client), messagesPath, tc.body) + + if rec.Code != tc.status || rec.Body.String() != tc.answer { + t.Errorf("response = %d %q, want %d %q", + rec.Code, rec.Body.String(), tc.status, tc.answer) + } + + if client.contactID != 0 { + t.Error("the chat client was asked to send") + } + }) + } +} + +// TestSendRefused: a send the chat client refuses, or answers with no +// message, gets an answer chosen for the reason, never the chat +// client's own words. +func TestSendRefused(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + client *fakeClient + status int + answer string + }{ + "contact deleted": { + &fakeClient{contacts: oneChat(), err: simplex.ErrContactNotReady}, + http.StatusConflict, `{"error":"the contact cannot receive messages"}`, + }, + "text too long": { + &fakeClient{contacts: oneChat(), err: simplex.ErrMessageTooLarge}, + http.StatusRequestEntityTooLarge, `{"error":"the text is too long"}`, + }, + "anything else": { + &fakeClient{contacts: oneChat(), err: errChat}, + http.StatusInternalServerError, `{"error":"the message could not be sent"}`, + }, + "no message in the answer": { + &fakeClient{contacts: oneChat(), sent: chatItem(t, `{"meta":{"itemId":13}, + "content":{"type":"sndDirectEvent"}}`)}, + http.StatusInternalServerError, + `{"error":"the chat client's answer could not be read"}`, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + rec := post(t, newAPI(credential, tc.client), messagesPath, `{"text":"hello"}`) + + if rec.Code != tc.status || rec.Body.String() != tc.answer+"\n" { + t.Errorf("response = %d %q, want %d %q", + rec.Code, rec.Body.String(), tc.status, tc.answer) + } + }) + } +} diff --git a/internal/api/webhooks.go b/internal/api/webhooks.go new file mode 100644 index 0000000..5578ce7 --- /dev/null +++ b/internal/api/webhooks.go @@ -0,0 +1,306 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "strconv" + "sync" + + "github.com/go-chi/chi/v5" +) + +const ( + // webhooksFile, in the data directory, keeps the webhooks. + webhooksFile = "webhooks.json" + + // idBytes is how many random bytes make a webhook's id. + idBytes = 16 + + // maxURLBytes caps a webhook's URL. + maxURLBytes = 2048 +) + +// errWebhooksFile is the error for a webhooks file that does not hold +// webhooks as the bot writes them. +var errWebhooksFile = errors.New("not a list of webhooks as the bot writes it") + +// webhook is a URL registered on a chat. +type webhook struct { + ID string `json:"id"` + ChatID int64 `json:"chat_id"` + URL string `json:"url"` +} + +// valid reports whether w is a webhook that registering could make. +func (w webhook) valid() bool { + id, err := hex.DecodeString(w.ID) + + return err == nil && len(id) == idBytes && w.ChatID > 0 && validURL(w.URL) +} + +// webhookList is how a list of webhooks is written: in the answer to GET, +// and in the webhooks file. +type webhookList struct { + Webhooks []webhook `json:"webhooks"` +} + +// Webhooks holds the webhooks registered on every chat, and keeps them in +// the webhooks file. It is safe for concurrent use. +type Webhooks struct { + path string + + // mu is held across each change, the write of the file included, so + // the changes reach the file one at a time and in order. + mu sync.Mutex + + // all is in the order the webhooks were registered. It is never nil, + // which would be written as null, a file ReadWebhooks refuses. + all []webhook +} + +// ReadWebhooks returns the webhooks kept in dir, and none if dir has no +// webhooks file. A file that cannot be read, or that holds anything but +// webhooks as the bot writes them, is an error, so that the bot neither +// starts without them nor later writes over them. +func ReadWebhooks(dir string) (*Webhooks, error) { + path := filepath.Join(dir, webhooksFile) + + b, err := os.ReadFile(path) //nolint:gosec // G304: the data directory's own file. + if errors.Is(err, fs.ErrNotExist) { + return &Webhooks{path: path, all: []webhook{}}, nil + } + + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + var file webhookList + + err = json.Unmarshal(b, &file) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + // The list is nil only if the file has none, as in {} or null; [] is + // an empty list. + if file.Webhooks == nil { + return nil, fmt.Errorf("reading %s: %w", path, errWebhooksFile) + } + + for _, w := range file.Webhooks { + if !w.valid() { + return nil, fmt.Errorf("reading %s: %w", path, errWebhooksFile) + } + } + + return &Webhooks{path: path, all: file.Webhooks}, nil +} + +// register registers hookURL on the chat chatID, and returns the new +// webhook and true; or, if that chat has a webhook with that URL already, +// that webhook and false. +func (s *Webhooks) register(chatID int64, hookURL string) (webhook, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + i := slices.IndexFunc(s.all, func(w webhook) bool { + return w.ChatID == chatID && w.URL == hookURL + }) + if i >= 0 { + return s.all[i], false, nil + } + + id := make([]byte, idBytes) + // Never fails: crypto/rand ends the program instead. + _, _ = rand.Read(id) + + added := webhook{ID: hex.EncodeToString(id), ChatID: chatID, URL: hookURL} + all := append(slices.Clone(s.all), added) + + err := s.write(all) + if err != nil { + return webhook{}, false, err + } + + s.all = all + + return added, true, nil +} + +// list returns the webhooks registered on the chat chatID, in the order +// they were registered. +func (s *Webhooks) list(chatID int64) []webhook { + s.mu.Lock() + defer s.mu.Unlock() + + hooks := make([]webhook, 0, len(s.all)) + + for _, w := range s.all { + if w.ChatID == chatID { + hooks = append(hooks, w) + } + } + + return hooks +} + +// remove removes the webhook id from the chat chatID, and returns false if +// that chat has no such webhook. +func (s *Webhooks) remove(chatID int64, id string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + all := slices.DeleteFunc(slices.Clone(s.all), func(w webhook) bool { + return w.ChatID == chatID && w.ID == id + }) + if len(all) == len(s.all) { + return false, nil + } + + err := s.write(all) + if err != nil { + return false, err + } + + s.all = all + + return true, nil +} + +// write replaces the webhooks file with one holding all. It writes a +// temporary file in the same directory and renames it over the old one, +// so a crash leaves the old file or the new one, never part of one, and a +// failure leaves the old file as it was. os.CreateTemp creates the +// temporary file with mode 0600, which the file keeps. +func (s *Webhooks) write(all []webhook) error { + b, err := json.MarshalIndent(webhookList{Webhooks: all}, "", " ") + if err != nil { + return fmt.Errorf("writing %s: %w", s.path, err) + } + + tmp, err := os.CreateTemp(filepath.Dir(s.path), webhooksFile+".*") + if err != nil { + return fmt.Errorf("writing %s: %w", s.path, err) + } + + // Synced before the rename, or a crash could leave the new file + // without its contents. + _, err = tmp.Write(append(b, '\n')) + + err = errors.Join(err, tmp.Sync(), tmp.Close()) + if err == nil { + err = os.Rename(tmp.Name(), s.path) + } + + if err != nil { + _ = os.Remove(tmp.Name()) + + return fmt.Errorf("writing %s: %w", s.path, err) + } + + return nil +} + +// validURL reports whether s can be a webhook's URL: an absolute http or +// https URL with a host, at most maxURLBytes long. +func validURL(s string) bool { + if len(s) > maxURLBytes { + return false + } + + u, err := url.Parse(s) + + return err == nil && (u.Scheme == "http" || u.Scheme == "https") && + u.Hostname() != "" +} + +// handleWebhooks lists a chat's webhooks. +func (h *handlers) handleWebhooks() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + chatID, ok := h.chatID(w, r) + if !ok { + return + } + + h.respond(w, http.StatusOK, webhookList{Webhooks: h.webhooks.list(chatID)}) + } +} + +// handleRegister registers the URL in the request's body on a chat, and +// answers with the webhook: 201 if it is new, 200 if the chat had it. +func (h *handlers) handleRegister() http.HandlerFunc { + type request struct { + URL string `json:"url"` + } + + return func(w http.ResponseWriter, r *http.Request) { + chatID, ok := h.chatID(w, r) + if !ok { + return + } + + var req request + if !h.decodeBody(w, r, &req, `{"url":"https://example.com/hook"}`) { + return + } + + if !validURL(req.URL) { + h.respondError(w, http.StatusBadRequest, "url must be an http or https URL "+ + "with a host, at most "+strconv.Itoa(maxURLBytes)+" bytes long") + + return + } + + hook, added, err := h.webhooks.register(chatID, req.URL) + if err != nil { + h.log.Error("registering a webhook", "error", err) + h.respondError(w, http.StatusInternalServerError, + "the webhooks could not be saved") + + return + } + + status := http.StatusOK + if added { + status = http.StatusCreated + } + + h.respond(w, status, hook) + } +} + +// handleRemove removes a webhook from a chat. +func (h *handlers) handleRemove() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + chatID, ok := h.chatID(w, r) + if !ok { + return + } + + removed, err := h.webhooks.remove(chatID, chi.URLParam(r, "webhook_id")) + if err != nil { + h.log.Error("removing a webhook", "error", err) + h.respondError(w, http.StatusInternalServerError, + "the webhooks could not be saved") + + return + } + + if !removed { + h.respondError(w, http.StatusNotFound, "no such webhook") + + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/internal/api/webhooks_test.go b/internal/api/webhooks_test.go new file mode 100644 index 0000000..e0a86a0 --- /dev/null +++ b/internal/api/webhooks_test.go @@ -0,0 +1,510 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "errors" + "io/fs" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "testing" + + "sneak.berlin/go/simplexcalc/internal/api" + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +const ( + hookURL = "https://example.com/hook" + + // unknownID is shaped like a webhook's id, and no webhook has it. + unknownID = "00112233445566778899aabbccddeeff" +) + +// hook is a webhook as the API answers with it. +type hook struct { + ID string `json:"id"` + ChatID int64 `json:"chat_id"` + URL string `json:"url"` +} + +// String returns h as the API writes it. +func (h hook) String() string { + return `{"id":"` + h.ID + `","chat_id":` + strconv.FormatInt(h.ChatID, 10) + + `,"url":"` + h.URL + `"}` +} + +// twoChats returns the bot's contacts in these tests: chat 3, which +// webhooksPath names, and chat 4, whose contact has deleted it. +func twoChats() []simplex.Contact { + return append(oneChat(), simplex.Contact{ContactID: 4, Status: "deleted"}) +} + +// readWebhooks returns the webhooks kept in dir. +func readWebhooks(t *testing.T, dir string) *api.Webhooks { + t.Helper() + + webhooks, err := api.ReadWebhooks(dir) + if err != nil { + t.Fatal(err) + } + + return webhooks +} + +// webhookAPI returns an API with webhooks, whose chats are those of +// twoChats. +func webhookAPI(webhooks *api.Webhooks) *http.Server { + return api.New(api.Params{ + Log: slog.New(slog.DiscardHandler), + Client: &fakeClient{contacts: twoChats()}, + UserID: 1, + Port: 8080, + Token: credential, + Webhooks: webhooks, + }) +} + +// register registers u on the chat whose webhooks are at path, and +// returns the webhook answered with. The answer must have status. +func register(t *testing.T, srv *http.Server, path, u string, status int) hook { + t.Helper() + + rec := post(t, srv, path, `{"url":"`+u+`"}`) + + var h hook + + err := json.Unmarshal(rec.Body.Bytes(), &h) + if err != nil || rec.Code != status || rec.Body.String() != h.String()+"\n" { + t.Fatalf("registering %s: %d %q, want %d and a webhook", + u, rec.Code, rec.Body.String(), status) + } + + return h +} + +// listed fails the test unless the chat whose webhooks are at path lists +// want, in that order. +func listed(t *testing.T, srv *http.Server, path string, want ...hook) { + t.Helper() + + hooks := make([]string, 0, len(want)) + for _, h := range want { + hooks = append(hooks, h.String()) + } + + body := `{"webhooks":[` + strings.Join(hooks, ",") + "]}\n" + + rec := request(t, srv, http.MethodGet, path, bearer) + if rec.Code != http.StatusOK || rec.Body.String() != body { + t.Errorf("GET %s: %d %q, want 200 %q", path, rec.Code, rec.Body.String(), body) + } +} + +// eachEndpoint lists the webhooks of the chat whose webhooks are at path, +// registers one and removes one, and returns the three answers. +func eachEndpoint( + t *testing.T, srv *http.Server, path string, +) []*httptest.ResponseRecorder { + t.Helper() + + return []*httptest.ResponseRecorder{ + request(t, srv, http.MethodGet, path, bearer), + post(t, srv, path, `{"url":"`+hookURL+`"}`), + request(t, srv, http.MethodDelete, path+"/"+unknownID, bearer), + } +} + +// onlyFile fails the test unless dir holds the webhooks file and nothing +// else: no temporary file is left. +func onlyFile(t *testing.T, dir string) { + t.Helper() + + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 1 || entries[0].Name() != "webhooks.json" { + t.Errorf("the directory holds %v (%v), want webhooks.json alone", entries, err) + } +} + +// noFile fails the test if dir has a webhooks file. +func noFile(t *testing.T, dir string) { + t.Helper() + + _, err := os.Stat(filepath.Join(dir, "webhooks.json")) + if !errors.Is(err, fs.ErrNotExist) { + t.Errorf("webhooks.json: %v, want it not written", err) + } +} + +// TestWebhooks: a URL registered on a chat gets 201 and a new id of 32 +// hexadecimal digits; the same URL again gets 200 and the same webhook. +// Each chat lists its own, in the order registered, the chat of a contact +// who deleted it included. +func TestWebhooks(t *testing.T) { + t.Parallel() + + srv := webhookAPI(readWebhooks(t, t.TempDir())) + + listed(t, srv, webhooksPath) + + first := register(t, srv, webhooksPath, hookURL, http.StatusCreated) + again := register(t, srv, webhooksPath, hookURL, http.StatusOK) + second := register(t, srv, webhooksPath, hookURL+"/2", http.StatusCreated) + other := register(t, srv, "/api/v1/chats/4/webhooks", hookURL, http.StatusCreated) + + if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(first.ID) || + first.ChatID != 3 || first.URL != hookURL { + t.Errorf("registered %v, want a new id, chat 3 and %s", first, hookURL) + } + + if again != first { + t.Errorf("registered again: %v, want %v", again, first) + } + + if second.ID == first.ID || other.ID == first.ID || other.ID == second.ID { + t.Errorf("ids repeat: %v, %v, %v", first, second, other) + } + + listed(t, srv, webhooksPath, first, second) + listed(t, srv, "/api/v1/chats/4/webhooks", other) +} + +// TestRemoveWebhook: a removed webhook gets 204 with no body and is no +// longer listed. Removing it again, removing a webhook through a chat it +// is not on, or removing an id no webhook has, gets 404. +func TestRemoveWebhook(t *testing.T) { + t.Parallel() + + srv := webhookAPI(readWebhooks(t, t.TempDir())) + first := register(t, srv, webhooksPath, hookURL, http.StatusCreated) + second := register(t, srv, webhooksPath, hookURL+"/2", http.StatusCreated) + + rec := request(t, srv, http.MethodDelete, webhooksPath+"/"+first.ID, bearer) + if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 { + t.Errorf("removing: %d %q, want 204 and no body", rec.Code, rec.Body.String()) + } + + noSuchWebhook := `{"error":"no such webhook"}` + "\n" + + for _, path := range []string{ + webhooksPath + "/" + first.ID, + "/api/v1/chats/4/webhooks/" + second.ID, + webhooksPath + "/" + unknownID, + } { + rec := request(t, srv, http.MethodDelete, path, bearer) + if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchWebhook { + t.Errorf("DELETE %s: %d %q, want 404 %q", + path, rec.Code, rec.Body.String(), noSuchWebhook) + } + } + + listed(t, srv, webhooksPath, second) +} + +// TestRegisterRefused: a body that is not JSON with a URL a webhook can +// have, or that is over 64 KiB, registers nothing. A URL of 2048 bytes +// is registered. +func TestRegisterRefused(t *testing.T) { + t.Parallel() + + notJSON := `{"error":"the body must be JSON such as ` + + `{\"url\":\"https://example.com/hook\"}"}` + "\n" + badURL := `{"error":"url must be an http or https URL with a host, ` + + `at most 2048 bytes long"}` + "\n" + longest := hookURL + "/" + strings.Repeat("a", 2048-len(hookURL)-1) + + for name, tc := range map[string]struct { + body string + status int + answer string + }{ + "no body": {"", http.StatusBadRequest, notJSON}, + "a bare URL": {hookURL, http.StatusBadRequest, notJSON}, + "url not a string": {`{"url":5}`, http.StatusBadRequest, notJSON}, + "no url": {`{}`, http.StatusBadRequest, badURL}, + "relative": {`{"url":"/hook"}`, http.StatusBadRequest, badURL}, + "no scheme": {`{"url":"example.com/hook"}`, http.StatusBadRequest, badURL}, + "another scheme": {`{"url":"ftp://example.com/"}`, http.StatusBadRequest, badURL}, + "no host": {`{"url":"https:///hook"}`, http.StatusBadRequest, badURL}, + "a port, no host": {`{"url":"http://:80/hook"}`, http.StatusBadRequest, badURL}, + "not a URL": {`{"url":"https://a b/"}`, http.StatusBadRequest, badURL}, + "over 2048 bytes": {`{"url":"` + longest + `a"}`, http.StatusBadRequest, badURL}, + "over 64 KiB": { + `{"url":"` + hookURL + "/" + strings.Repeat("a", 64<<10) + `"}`, + http.StatusRequestEntityTooLarge, `{"error":"the body is too large"}` + "\n", + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + rec := post(t, webhookAPI(readWebhooks(t, dir)), webhooksPath, tc.body) + + if rec.Code != tc.status || rec.Body.String() != tc.answer { + t.Errorf("response = %d %q, want %d %q", + rec.Code, rec.Body.String(), tc.status, tc.answer) + } + + noFile(t, dir) + }) + } + + register(t, webhookAPI(readWebhooks(t, t.TempDir())), webhooksPath, longest, + http.StatusCreated) +} + +// TestWebhooksNoSuchChat: an id that GET /api/v1/chats does not list gets +// 404 from each webhook endpoint, 1 and 2 included, and nothing is +// written. When the chats cannot be read to look the id up, the answer +// is 500. +func TestWebhooksNoSuchChat(t *testing.T) { + t.Parallel() + + for _, id := range []string{ + "1", "2", "5", "tester", "0", "-3", "3.5", "99999999999999999999", + } { + dir := t.TempDir() + + for _, rec := range eachEndpoint(t, webhookAPI(readWebhooks(t, dir)), + "/api/v1/chats/"+id+"/webhooks") { + if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchChat { + t.Errorf("chat %q: %d %q, want 404 %q", + id, rec.Code, rec.Body.String(), noSuchChat) + } + } + + noFile(t, dir) + } + + srv := api.New(api.Params{ + Log: slog.New(slog.DiscardHandler), + Client: &fakeClient{contactsErr: errChat}, + Token: credential, + Webhooks: readWebhooks(t, t.TempDir()), + }) + want := `{"error":"the chats could not be read"}` + "\n" + + for _, rec := range eachEndpoint(t, srv, webhooksPath) { + if rec.Code != http.StatusInternalServerError || rec.Body.String() != want { + t.Errorf("chats unreadable: %d %q, want 500 %q", + rec.Code, rec.Body.String(), want) + } + } +} + +// TestWebhooksKept: a new read of the directory finds the webhooks +// registered, and none once they are removed. +func TestWebhooksKept(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + srv := webhookAPI(readWebhooks(t, dir)) + first := register(t, srv, webhooksPath, hookURL, http.StatusCreated) + second := register(t, srv, "/api/v1/chats/4/webhooks", hookURL, http.StatusCreated) + + srv = webhookAPI(readWebhooks(t, dir)) + listed(t, srv, webhooksPath, first) + listed(t, srv, "/api/v1/chats/4/webhooks", second) + + for _, path := range []string{ + webhooksPath + "/" + first.ID, "/api/v1/chats/4/webhooks/" + second.ID, + } { + rec := request(t, srv, http.MethodDelete, path, bearer) + if rec.Code != http.StatusNoContent { + t.Fatalf("DELETE %s: %d %q, want 204", path, rec.Code, rec.Body.String()) + } + } + + srv = webhookAPI(readWebhooks(t, dir)) + listed(t, srv, webhooksPath) + listed(t, srv, "/api/v1/chats/4/webhooks") +} + +// TestWebhooksFile: the file has mode 0600, and a change replaces it with +// a new file rather than writing into it: through a second name, the old +// file still holds what it held. No temporary file is left. +func TestWebhooksFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + file := filepath.Join(dir, "webhooks.json") + srv := webhookAPI(readWebhooks(t, dir)) + register(t, srv, webhooksPath, hookURL, http.StatusCreated) + + info, err := os.Stat(file) + if err != nil || info.Mode() != 0o600 { + t.Errorf("webhooks.json: %v (%v), want mode 0600", info, err) + } + + before, err := os.ReadFile(file) //nolint:gosec // G304: the test's own file. + if err != nil { + t.Fatal(err) + } + + old := filepath.Join(t.TempDir(), "old") + + err = os.Link(file, old) + if err != nil { + t.Fatal(err) + } + + register(t, srv, webhooksPath, hookURL+"/2", http.StatusCreated) + + after, err := os.ReadFile(old) //nolint:gosec // G304: the test's own file. + if err != nil || !bytes.Equal(after, before) { + t.Errorf("the old file holds %q (%v), want %q as before", after, err, before) + } + + onlyFile(t, dir) +} + +// TestWebhooksWriteFailure: a change whose file cannot be replaced gets +// 500 and is not made, and its temporary file is removed. A directory +// stands where the file goes, which a rename cannot replace; a directory +// without write permission would not do, as tests may run as root. +func TestWebhooksWriteFailure(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + file := filepath.Join(dir, "webhooks.json") + srv := webhookAPI(readWebhooks(t, dir)) + first := register(t, srv, webhooksPath, hookURL, http.StatusCreated) + + err := os.Remove(file) + if err == nil { + err = os.Mkdir(file, 0o700) + } + + if err != nil { + t.Fatal(err) + } + + want := `{"error":"the webhooks could not be saved"}` + "\n" + + for _, rec := range []*httptest.ResponseRecorder{ + post(t, srv, webhooksPath, `{"url":"`+hookURL+`/2"}`), + request(t, srv, http.MethodDelete, webhooksPath+"/"+first.ID, bearer), + } { + if rec.Code != http.StatusInternalServerError || rec.Body.String() != want { + t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want) + } + } + + listed(t, srv, webhooksPath, first) + onlyFile(t, dir) +} + +// TestReadWebhooksRefuses: a webhooks file that holds anything but +// webhooks as the bot writes them cannot be read, and the error names +// it. An absent file is no webhooks. +func TestReadWebhooksRefuses(t *testing.T) { + t.Parallel() + + record := func(id, chatID, url string) string { + return `{"webhooks":[{"id":"` + id + `","chat_id":` + chatID + + `,"url":"` + url + `"}]}` + } + + for name, contents := range map[string]string{ + "empty file": "", + "a word": "webhooks", + "cut short": `{"webhooks":[`, + "more after it": `{"webhooks":[]} {}`, + "null": "null", + "no list": "{}", + "null list": `{"webhooks":null}`, + "list alone": "[]", + "empty webhook": `{"webhooks":[{}]}`, + "id not hex": record("not-an-id", "3", hookURL), + "id too short": record("0011", "3", hookURL), + "chat 0": record(unknownID, "0", hookURL), + "url of another kind": record(unknownID, "3", "ftp://example.com/"), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + err := os.WriteFile(filepath.Join(dir, "webhooks.json"), []byte(contents), 0o600) + if err != nil { + t.Fatal(err) + } + + _, err = api.ReadWebhooks(dir) + if err == nil || !strings.Contains(err.Error(), "webhooks.json") { + t.Errorf("ReadWebhooks = %v, want an error naming webhooks.json", err) + } + }) + } + + listed(t, webhookAPI(readWebhooks(t, t.TempDir())), webhooksPath) +} + +// TestWebhooksConcurrent: registrations at the same time, among listings +// and removals, each get their own webhook, all kept; the same URL +// registered at the same time is registered once. +func TestWebhooksConcurrent(t *testing.T) { + t.Parallel() + + const n = 20 + + dir := t.TempDir() + webhooks := readWebhooks(t, dir) + answers := make([]*httptest.ResponseRecorder, 2*n) + + var wg sync.WaitGroup + + for i := range n { + wg.Go(func() { + answers[i] = post(t, webhookAPI(webhooks), webhooksPath, + `{"url":"`+hookURL+"/"+strconv.Itoa(i)+`"}`) + }) + wg.Go(func() { + answers[n+i] = post(t, webhookAPI(webhooks), webhooksPath, + `{"url":"`+hookURL+`"}`) + }) + wg.Go(func() { + srv := webhookAPI(webhooks) + request(t, srv, http.MethodGet, webhooksPath, bearer) + request(t, srv, http.MethodDelete, webhooksPath+"/"+unknownID, bearer) + }) + } + + wg.Wait() + + created := 0 + ids := map[string]bool{} + + for _, rec := range answers { + var h hook + + _ = json.Unmarshal(rec.Body.Bytes(), &h) + ids[h.ID] = true + + if rec.Code == http.StatusCreated { + created++ + } + } + + if created != n+1 || len(ids) != n+1 { + t.Errorf("%d registered, %d ids; want %d of each", created, len(ids), n+1) + } + + rec := request(t, webhookAPI(readWebhooks(t, dir)), + http.MethodGet, webhooksPath, bearer) + + var got struct { + Webhooks []hook `json:"webhooks"` + } + + err := json.Unmarshal(rec.Body.Bytes(), &got) + if err != nil || len(got.Webhooks) != n+1 { + t.Errorf("read again: %d webhooks (%v), want %d", len(got.Webhooks), err, n+1) + } +} diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 4ba2eb0..6d343a1 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -8,12 +8,15 @@ import ( "errors" "fmt" "log/slog" + "net/http" "os" "path/filepath" "strconv" "time" + "sneak.berlin/go/simplexcalc/internal/api" "sneak.berlin/go/simplexcalc/internal/calc" + "sneak.berlin/go/simplexcalc/internal/config" "sneak.berlin/go/simplexcalc/internal/simplex" ) @@ -22,14 +25,14 @@ import ( const DisplayName = "calc" // Welcome is sent to everyone whose contact request the bot accepts. -const Welcome = "Send me arithmetic, such as 2 + 2 or 5 * 5/2, " + +const Welcome = "Send me arithmetic, such as 2 + 2, 5 * 5/2, 2^10 or 7 % 3, " + "and I will reply with the result." -const ( - // chatPort is where the chat client serves its API, on localhost - // inside the bot's own container. - chatPort = 5225 +// ChatPort is where the chat client serves its API, on localhost inside +// the bot's own container. +const ChatPort = 5225 +const ( // connectTimeout bounds the wait for a freshly started chat client // to open its API, which includes creating or migrating the // database. @@ -42,26 +45,49 @@ const ( // retryInterval paces the connection attempts. retryInterval = 250 * time.Millisecond + // apiStopTimeout is how long API requests in progress get to finish + // when the bot stops, before their connections are closed. + apiStopTimeout = 5 * time.Second + dataDirMode = 0o700 ) var errExited = errors.New("simplex-chat exited") -// Run starts the chat client with its database in dataDir, connects to -// it, sets up the bot's address, and answers messages until ctx is -// cancelled — which is a clean stop and returns nil — or until the chat -// client or the connection to it fails, which returns the error. -func Run(ctx context.Context, log *slog.Logger, dataDir string) error { - err := os.MkdirAll(dataDir, dataDirMode) +// Run reads the webhooks kept in cfg.DataDir, starts the chat client with +// its database there, serving its API on localhost at chatPort, connects +// to it, sets up the bot's address, then answers messages, posts them to +// the webhooks and serves the bot's API until ctx is cancelled — which is +// a clean stop and returns nil — or until the chat client, the connection +// to it or the API's listener fails, which returns the error. +func Run( + ctx context.Context, log *slog.Logger, cfg *config.Config, chatPort int, +) error { + err := os.MkdirAll(cfg.DataDir, dataDirMode) if err != nil { return fmt.Errorf("creating data directory: %w", err) } + // Before the chat client starts, so that a file that cannot be read + // stops the bot as configuration that cannot be read does. + webhooks, err := api.ReadWebhooks(cfg.DataDir) + if err != nil { + return err + } + + // Posts the messages the event handler hands it to the webhooks. Its + // stop, deferred first, runs last, once the chat client has exited + // and can send no more messages; cancelling ctx does not reach it. + deliveries := api.StartDeliveries(context.WithoutCancel(ctx), log, webhooks) + defer deliveries.Stop() + // Cancelling this stops the chat client; the deferred wait makes // Run return only once it has exited, whatever path Run takes. - cliCtx, stopCLI := context.WithCancel(ctx) + // Cancelling ctx does not reach it, so that the API, stopped first, + // can finish its requests while the chat client still answers. + cliCtx, stopCLI := context.WithCancel(context.WithoutCancel(ctx)) - cli, err := simplex.StartCLI(cliCtx, log, filepath.Join(dataDir, "simplex"), + cli, err := simplex.StartCLI(cliCtx, log, filepath.Join(cfg.DataDir, "simplex"), DisplayName, chatPort) if err != nil { stopCLI() @@ -74,18 +100,39 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error { <-cli.Done() }() - client, err := connect(ctx, log, cli) + client, err := connect(ctx, log, cli, chatPort, handle(log, deliveries)) if err != nil { return err } defer func() { _ = client.Close() }() - err = setUp(ctx, log, client) + user, err := setUp(ctx, log, client) if err != nil { return err } + srv := api.New(api.Params{ + Log: log, + Client: client, + UserID: user.UserID, + Port: cfg.Port, + Token: cfg.APIToken, + Webhooks: webhooks, + }) + + served := make(chan error, 1) + + go func() { + served <- srv.ListenAndServe() + }() + + // Deferred last, so it runs first: requests in progress finish while + // the chat client is still there to answer them. + defer stopAPI(ctx, log, srv) + + log.Info("starting the API", "port", cfg.Port) + select { case <-ctx.Done(): return nil @@ -93,12 +140,31 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error { return fmt.Errorf("%w: %w", simplex.ErrClosed, client.Err()) case <-cli.Done(): return fmt.Errorf("%w: %w", errExited, cli.Err()) + case err := <-served: + return fmt.Errorf("serving the API: %w", err) } } -// connect waits for the chat client to open its API and connects to it. +// stopAPI stops the API server, giving requests in progress up to +// apiStopTimeout to finish before closing their connections. +func stopAPI(ctx context.Context, log *slog.Logger, srv *http.Server) { + // ctx is usually cancelled by now: that is why the bot is stopping. + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), apiStopTimeout) + defer cancel() + + err := srv.Shutdown(ctx) + if err != nil { + log.Warn("stopping the API", "error", err) + + _ = srv.Close() + } +} + +// connect waits for the chat client to open its API on chatPort and +// connects to it, passing its events to onEvent. func connect( - ctx context.Context, log *slog.Logger, cli *simplex.CLI, + ctx context.Context, log *slog.Logger, cli *simplex.CLI, chatPort int, + onEvent simplex.EventHandler, ) (*simplex.Client, error) { ctx, cancel := context.WithTimeout(ctx, connectTimeout) defer cancel() @@ -106,7 +172,7 @@ func connect( url := "ws://127.0.0.1:" + strconv.Itoa(chatPort) for { - client, err := simplex.Dial(ctx, url, log, handle(log)) + client, err := simplex.Dial(ctx, url, log, onEvent) if err == nil { return client, nil } @@ -125,19 +191,22 @@ func connect( // setUp gives the bot a long-term contact address, creating it on the // first start, and sets it to accept every contact request and to greet // each new contact. The settings are written on every start, so an -// address whose settings were changed by hand is put right. -func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error { +// address whose settings were changed by hand is put right. It returns +// the bot's user profile. +func setUp( + ctx context.Context, log *slog.Logger, client *simplex.Client, +) (simplex.User, error) { ctx, cancel := context.WithTimeout(ctx, setupTimeout) defer cancel() user, err := client.ActiveUser(ctx) if err != nil { - return fmt.Errorf("reading the bot's profile: %w", err) + return user, fmt.Errorf("reading the bot's profile: %w", err) } link, ok, err := client.Address(ctx, user.UserID) if err != nil { - return fmt.Errorf("reading the bot's address: %w", err) + return user, fmt.Errorf("reading the bot's address: %w", err) } if !ok { @@ -145,7 +214,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error link, err = client.CreateAddress(ctx, user.UserID) if err != nil { - return fmt.Errorf("creating the bot's address: %w", err) + return user, fmt.Errorf("creating the bot's address: %w", err) } } @@ -154,7 +223,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error AutoReply: &simplex.MsgContent{Type: "text", Text: Welcome}, }) if err != nil { - return fmt.Errorf("setting the bot's address to accept everyone: %w", err) + return user, fmt.Errorf("setting the bot's address to accept everyone: %w", err) } log.Info("ready", @@ -163,11 +232,12 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error "full_address", link.FullLink, ) - return nil + return user, nil } -// handle answers each text message a contact sends. -func handle(log *slog.Logger) simplex.EventHandler { +// handle answers each text message a contact sends, and hands every +// message a contact sends to deliveries, for the webhooks of its chat. +func handle(log *slog.Logger, deliveries *api.Deliveries) simplex.EventHandler { return func(c *simplex.Client, ev simplex.Event) { switch ev.Type { case simplex.TypeNewChatItems: @@ -181,6 +251,9 @@ func handle(log *slog.Logger) simplex.EventHandler { } for _, item := range r.ChatItems { + // Never waits, so no webhook holds up the replies. + deliveries.Add(item) + msg, ok := item.Message() if !ok { continue @@ -221,10 +294,12 @@ func Reply(text string) string { calc.MaxInputLength) case errors.Is(err, calc.ErrDivisionByZero): return "I cannot divide by zero." - case errors.Is(err, calc.ErrTooLarge): - return "The result is too large for me." + case errors.Is(err, calc.ErrOutOfRange): + return "That needs a number too large or too small for me." + case errors.Is(err, calc.ErrNoRealResult): + return "A negative number to a fractional power has no real result." default: - return "I only understand arithmetic: numbers, + - * / and " + - "parentheses, such as 5 * 5/2." + return "I only understand arithmetic: numbers, + - * /, ^ for a power, " + + "% for a remainder, and parentheses, such as 5 * 5/2 or 2^10." } } diff --git a/internal/bot/bot_test.go b/internal/bot/bot_test.go index 3f7971e..e76914e 100644 --- a/internal/bot/bot_test.go +++ b/internal/bot/bot_test.go @@ -16,6 +16,9 @@ func TestReply(t *testing.T) { for in, want := range map[string]string{ "2 + 2": "4", "5 * 5/2": "12.5", + "2^10": "1024", + "7 % 3": "1", + "2^1200": "1.7218479456385751e+361", } { if got := bot.Reply(in); got != want { t.Errorf("Reply(%q) = %q, want %q", in, got, want) @@ -23,9 +26,11 @@ func TestReply(t *testing.T) { } for in, want := range map[string]string{ - "hello": "I only understand arithmetic", - "1 / 0": "I cannot divide by zero.", - "1e400": "The result is too large for me.", + "hello": "I only understand arithmetic", + "1 / 0": "I cannot divide by zero.", + "1e1300": "That needs a number too large or too small for me.", + "1e-1300": "That needs a number too large or too small for me.", + "(-8)^0.5": "A negative number to a fractional power has no real", strings.Repeat("1+", calc.MaxInputLength) + "1": "That is too long for me", } { if got := bot.Reply(in); !strings.HasPrefix(got, want) { diff --git a/internal/bot/run_test.go b/internal/bot/run_test.go new file mode 100644 index 0000000..67adae2 --- /dev/null +++ b/internal/bot/run_test.go @@ -0,0 +1,423 @@ +package bot_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "sneak.berlin/go/simplexcalc/internal/bot" + "sneak.berlin/go/simplexcalc/internal/config" + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +const ( + // credential is what the bot's API is configured with here. + credential = "a-credential-for-these-tests" //nolint:gosec // G101: invented for tests + + // asked is the file the stand-in chat client creates in the data + // directory when it is asked for the contacts. + asked = "asked-for-contacts" + + // contactsDelay is how long the stand-in then holds its answer: + // long enough for the test to stop the bot meanwhile, and well + // within the 5 seconds the API gets to finish its requests. + contactsDelay = time.Second + + // sent starts the name of the file the stand-in writes in the data + // directory for each message the bot sends, holding the command. + sent = "sent-" +) + +// twoMessages is the event the stand-in sends once the bot is set up: a +// contact sending the bot two messages. It is reduced to the fields the +// bot reads. +const twoMessages = `{"type":"newChatItems","chatItems":[ +{"chatInfo":{"type":"direct","contact":{"contactId":3}}, + "chatItem":{"chatDir":{"type":"directRcv"}, + "meta":{"itemId":41,"itemTs":"2026-09-29T03:14:34Z"}, + "content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"2 + 2"}}}}, +{"chatInfo":{"type":"direct","contact":{"contactId":3}}, + "chatItem":{"chatDir":{"type":"directRcv"}, + "meta":{"itemId":42,"itemTs":"2026-09-29T03:14:35Z"}, + "content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"3 * 3"}}}}]}` + +// TestMain lets this test binary be the chat client as well: started +// under the chat client's name, as standInPath arranges, it is the +// stand-in instead of running the tests. +func TestMain(m *testing.M) { + if filepath.Base(os.Args[0]) == simplex.Binary { + standIn() // never returns + } + + m.Run() +} + +// standIn plays the chat client: it serves the WebSocket API on the +// port it is given, on localhost, and answers the commands the bot +// sends. SIGTERM ends it, as it ends the real client. Unlike the real +// client, it also exits when the bot hangs up, so that it never +// outlives a test run that was cut short. +func standIn() { + arg := func(name string) string { + return os.Args[slices.Index(os.Args, name)+1] + } + + dir := filepath.Dir(arg("--database")) + + srv := &http.Server{ + Addr: "127.0.0.1:" + arg("--chat-server-port"), + ReadHeaderTimeout: time.Second, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + answer(w, r, dir) + }), + } + + err := srv.ListenAndServe() + _, _ = fmt.Fprintln(os.Stderr, err) + + os.Exit(1) +} + +// answer answers the commands on one connection with records reduced to +// the fields the bot reads. Asked for the contacts, it first creates the +// file asked in dir, then holds its answer for contactsDelay. Once the +// bot is set up, it sends twoMessages. Each message the bot sends, it +// writes to a file in dir, named sent and the command's id. +func answer(w http.ResponseWriter, r *http.Request, dir string) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + + records := map[string]string{ + "/user": `{"type":"activeUser","user":{"userId":1}}`, + "/_show_address": `{"type":"userContactLink","contactLink":{}}`, + "/_address_settings": `{"type":"userContactLinkUpdated"}`, + "/_contacts": `{"type":"contactsList","contacts":[{"contactId":3,` + + `"profile":{"displayName":"tester"},"contactStatus":"active"}]}`, + } + + for { + var cmd map[string]string + + err = conn.ReadJSON(&cmd) + if err != nil { + os.Exit(0) + } + + name, _, _ := strings.Cut(cmd["cmd"], " ") + + if name == "/_send" { + _ = os.WriteFile(filepath.Join(dir, sent+cmd["corrId"]), + []byte(cmd["cmd"]), 0o600) + + continue + } + + record, ok := records[name] + if !ok { + continue + } + + if name == "/_contacts" { + _ = os.WriteFile(filepath.Join(dir, asked), nil, 0o600) + + time.Sleep(contactsDelay) + } + + _ = conn.WriteJSON(map[string]any{ + "corrId": cmd["corrId"], + "resp": json.RawMessage(record), + }) + + // The last command of the bot's set-up. + if name == "/_address_settings" { + _ = conn.WriteJSON(map[string]any{"resp": json.RawMessage(twoMessages)}) + } + } +} + +// TestStopDuringRequest: a request that is waiting on the chat client +// when the bot is told to stop still gets the chat client's answer, +// because the chat client is stopped only once the API has stopped. +func TestStopDuringRequest(t *testing.T) { + t.Setenv("PATH", standInPath(t)) + + cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t), APIToken: credential} + stop, done := runBot(t, cfg) + + // Stop the bot once the request below is waiting on the chat client. + go func() { + for { + _, err := os.Stat(filepath.Join(cfg.DataDir, asked)) + if err == nil { + stop() + } + + select { + case <-done: + return + case <-time.After(10 * time.Millisecond): + } + } + }() + + status, body := getChats(t, cfg.Port, done) + + want := `{"chats":[{"id":3,"display_name":"tester","contact_deleted":false}]}` + "\n" + if status != http.StatusOK || body != want { + t.Errorf("GET /api/v1/chats = %d %q, want 200 %q", status, body, want) + } +} + +// TestSlowWebhook: a webhook that takes the bot's POSTs and never answers +// them holds up neither the bot's replies nor its stop. +func TestSlowWebhook(t *testing.T) { + t.Setenv("PATH", standInPath(t)) + + posts := make(chan string, 2) + receiver := httptest.NewServer(http.HandlerFunc( + func(_ http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + posts <- string(body) + + <-r.Context().Done() // until the bot abandons the POST + })) + t.Cleanup(receiver.Close) + + cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t), APIToken: credential} + + err := os.WriteFile(filepath.Join(cfg.DataDir, "webhooks.json"), + []byte(`{"webhooks":[{"id":"00112233445566778899aabbccddeeff",`+ + `"chat_id":3,"url":"`+receiver.URL+`"}]}`), 0o600) + if err != nil { + t.Fatal(err) + } + + stop, done := runBot(t, cfg) + + // The webhook gets both messages, and holds on to them... + got := []string{receive(t, posts, done), receive(t, posts, done)} + slices.Sort(got) + + want := []string{ + `{"chat_id":3,"message":{"id":41,"direction":"received",` + + `"type":"text","text":"2 + 2","time":"2026-09-29T03:14:34Z"}}`, + `{"chat_id":3,"message":{"id":42,"direction":"received",` + + `"type":"text","text":"3 * 3","time":"2026-09-29T03:14:35Z"}}`, + } + if !slices.Equal(got, want) { + t.Errorf("posted %q, want %q", got, want) + } + + // ...while the bot answers both. + replies := []string{ + `/_send @3 json [{"quotedItemId":41,` + + `"msgContent":{"type":"text","text":"4"},"mentions":{}}]`, + `/_send @3 json [{"quotedItemId":42,` + + `"msgContent":{"type":"text","text":"9"},"mentions":{}}]`, + } + + deadline := time.Now().Add(5 * time.Second) + + for !slices.Equal(sentMessages(t, cfg.DataDir), replies) { + if time.Now().After(deadline) { + t.Fatalf("sent %q, want %q", sentMessages(t, cfg.DataDir), replies) + } + + time.Sleep(10 * time.Millisecond) + } + + // Stopping abandons the POSTs, rather than give the webhook the 10 + // seconds it has to answer. + stop() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Run did not return while a webhook held a POST") + } +} + +// TestUnreadableWebhooks: a webhooks file that cannot be read stops Run +// before it starts the chat client. +func TestUnreadableWebhooks(t *testing.T) { + // No chat client on PATH: starting one would fail with another error. + t.Setenv("PATH", t.TempDir()) + + cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t)} + + err := os.WriteFile(filepath.Join(cfg.DataDir, "webhooks.json"), []byte("{"), 0o600) + if err != nil { + t.Fatal(err) + } + + err = bot.Run(t.Context(), slog.New(slog.DiscardHandler), cfg, freePort(t)) + if err == nil || !strings.Contains(err.Error(), "webhooks.json") { + t.Errorf("Run = %v, want an error naming webhooks.json", err) + } +} + +// standInPath returns a directory holding this test binary under the +// chat client's name. Run starts the chat client from PATH, so with PATH +// set to it, Run starts the stand-in. +func standInPath(t *testing.T) string { + t.Helper() + + bin := t.TempDir() + + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + + err = os.Symlink(exe, filepath.Join(bin, simplex.Binary)) + if err != nil { + t.Fatal(err) + } + + return bin +} + +// runBot runs the bot with cfg until stop is called or the test ends, +// and fails the test if Run returns an error. done is closed once Run +// has returned. +func runBot(t *testing.T, cfg *config.Config) (context.CancelFunc, <-chan struct{}) { + t.Helper() + + // Never bot.ChatPort: a real chat client may be listening there. + chatPort := freePort(t) + ctx, stop := context.WithCancel(t.Context()) + done := make(chan struct{}) + + var runErr error + + go func() { + defer close(done) + + runErr = bot.Run(ctx, slog.New(slog.DiscardHandler), cfg, chatPort) + }() + + t.Cleanup(func() { + stop() + <-done + + if runErr != nil { + t.Errorf("Run: %v", runErr) + } + }) + + return stop, done +} + +// receive returns the next of posts. It fails the test if none comes +// within 10 seconds, or if Run returns first, which closes done. +func receive(t *testing.T, posts <-chan string, done <-chan struct{}) string { + t.Helper() + + select { + case p := <-posts: + return p + case <-done: + t.Fatal("Run returned before the webhook got the messages") + case <-time.After(10 * time.Second): + t.Fatal("the webhook did not get the messages") + } + + return "" +} + +// sentMessages returns the commands of the messages the bot has sent, as +// the stand-in wrote them in dir, sorted. +func sentMessages(t *testing.T, dir string) []string { + t.Helper() + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + + var cmds []string + + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), sent) { + continue + } + + //nolint:gosec // G304: the test's own file. + cmd, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + t.Fatal(err) + } + + cmds = append(cmds, string(cmd)) + } + + slices.Sort(cmds) + + return cmds +} + +// freePort returns a TCP port that nothing listens on at the moment. +func freePort(t *testing.T) int { + t.Helper() + + l, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", ":0") + if err != nil { + t.Fatal(err) + } + + defer func() { _ = l.Close() }() + + return int(netip.MustParseAddrPort(l.Addr().String()).Port()) +} + +// getChats asks the bot's API for the chats, trying again while the API +// is not listening yet, and returns the answer's status and body. It +// fails the test if Run returns first, which closes done. +func getChats(t *testing.T, port int, done <-chan struct{}) (int, string) { + t.Helper() + + url := "http://127.0.0.1:" + strconv.Itoa(port) + "/api/v1/chats" + + for { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + + req.Header.Set("Authorization", "Bearer "+credential) + + resp, err := http.DefaultClient.Do(req) + if err == nil { + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + if err != nil { + t.Fatal(err) + } + + return resp.StatusCode, string(body) + } + + select { + case <-done: + t.Fatal("Run returned before the API answered") + case <-time.After(50 * time.Millisecond): + } + } +} diff --git a/internal/calc/calc.go b/internal/calc/calc.go index 2f2bd65..17801fb 100644 --- a/internal/calc/calc.go +++ b/internal/calc/calc.go @@ -1,29 +1,45 @@ // Package calc evaluates the arithmetic people send the bot: decimal -// numbers, + - * /, unary minus and parentheses. +// numbers, + - * / % ^, signs and parentheses. // -// The expression is parsed by go/parser and computed by go/constant, -// which does exact rational arithmetic: 5 * 5/2 is exactly 12.5, and -// 0.1 + 0.2 is exactly 0.3, so a result carries no binary floating -// point noise until the moment it is formatted. +// The expression is read by a small parser of its own, because Go's +// grammar has no power operator (^ is XOR there), and computed by +// go/constant, which does exact rational arithmetic: 5 * 5/2 is exactly +// 12.5, and 0.1 + 0.2 is exactly 0.3, so a result carries no binary +// floating point noise until the moment it is formatted. A power is the +// exception: one with a fractional exponent, or whose result go/constant +// cannot hold exactly, is computed in float64. package calc import ( "errors" - "go/ast" "go/constant" - "go/parser" "go/token" "math" + "math/big" "regexp" "strconv" "strings" ) -// MaxInputLength caps an expression, in bytes, so a message cannot make -// the bot do unbounded work. Every operation's cost grows with the size -// of its operands, and the operands can only grow with the input. +// MaxInputLength caps an expression, in bytes. With maxExactExponent, +// which caps a power computed exactly, and bitLimit, which caps every +// number, it keeps a message from making the bot do unbounded work. const MaxInputLength = 256 +// bitLimit caps the numerator and denominator of every number: see +// exact. +const bitLimit = 4096 + +// maxExactExponent is the largest exponent, either way, of a power +// computed exactly. Past it, x^n has a numerator or denominator of more +// than 4096 bits, which go/constant holds only rounded, unless x is 0 or +// 1, and float64 computes those exactly. +const maxExactExponent = 4096 + +// smallestNormal is the smallest positive normal double, about 2.2e-308. +// Below it a double keeps fewer digits, down to one. +const smallestNormal = 0x1p-1022 + // Results of magnitude plainUpper or more are written in exponent form // (1e+21 rather than twenty-two digits), and so are fractions smaller // than plainLower (1e-07 rather than 0.0000001). @@ -32,150 +48,467 @@ const ( plainLower = 1e-6 ) +// A result past the normal range of a double is written to +// significantDigits significant digits, the most the shortest form of a +// double takes. It is rounded to them from a float of floatPrecision +// bits, the bits a numerator or denominator can hold and 64 more for the +// digits, so that the float rounds as the exact result would. The square +// roots of a power's base past that range are taken in such a float too: +// see nonNegativePower. +const ( + significantDigits = 17 + floatPrecision = bitLimit + 64 +) + +// The precedence of the binary operators: the higher, the tighter the +// operator binds. +const ( + sumPrecedence = iota + 1 + productPrecedence + powerPrecedence +) + // Errors returned by Evaluate. The bot turns each into a reply. var ( ErrTooLong = errors.New("expression too long") ErrNotArithmetic = errors.New("not an arithmetic expression") ErrDivisionByZero = errors.New("division by zero") - ErrTooLarge = errors.New("result too large") + ErrOutOfRange = errors.New("number too large or too small") + ErrNoRealResult = errors.New("no real result") ) -// decimalLiteral is the only number syntax accepted. Go's own literal -// syntax is wider, and parts of it are traps for someone typing -// arithmetic: 010 is octal 8, and 0x10, 1_000 and 1i are not what a -// calculator user means by a number. -var decimalLiteral = regexp.MustCompile( - `^([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$`, +// decimal is the only number syntax accepted. Go's own literal syntax is +// wider, and parts of it are traps for someone typing arithmetic: 010 is +// octal 8, and 0x10, 1_000 and 1i are not what a calculator user means +// by a number. Here the x, _ or i matches no token and is refused. +const decimal = `([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?` + +var ( + // nextToken matches the token at the start of the input, after any + // whitespace: an operator, a parenthesis or a number. ** comes + // before * so that it is read as one token. + nextToken = regexp.MustCompile(`^\s*(\*\*|[-+*/%^()]|` + decimal + `)`) + + // decimalLiteral matches a token that is a number. + decimalLiteral = regexp.MustCompile(`^` + decimal + `$`) ) // Evaluate computes an arithmetic expression and returns its result as -// text: whole numbers without a decimal point, fractions in the -// shortest form that reads back as the same float64. +// text, written as format describes. func Evaluate(input string) (string, error) { s := strings.TrimSpace(input) if len(s) > MaxInputLength { return "", ErrTooLong } - if s == "" { - return "", ErrNotArithmetic - } - - expr, err := parser.ParseExpr(s) - if err != nil { - return "", ErrNotArithmetic - } - - v, err := eval(expr) + tokens, err := tokenize(s) if err != nil { return "", err } - return format(v) + p := parser{tokens: tokens} + + v, err := p.expr(sumPrecedence) + if err != nil { + return "", err + } + + if p.next() != "" { + return "", ErrNotArithmetic + } + + return format(v), nil } -// eval walks the syntax tree, allowing only the node types and -// operators of arithmetic. Anything else — identifiers, calls, strings, -// shifts, comparisons — is refused, not evaluated. -func eval(e ast.Expr) (constant.Value, error) { - switch n := e.(type) { - case *ast.BasicLit: - return literal(n) - case *ast.ParenExpr: - return eval(n.X) - case *ast.UnaryExpr: - if n.Op != token.ADD && n.Op != token.SUB { +// tokenize splits an expression into operators, parentheses and +// numbers, and refuses anything else. ** is returned as ^. +func tokenize(s string) ([]string, error) { + var tokens []string + + for strings.TrimSpace(s) != "" { + m := nextToken.FindStringSubmatch(s) + if m == nil { return nil, ErrNotArithmetic } - x, err := eval(n.X) + tok := m[1] + if tok == "**" { + tok = "^" + } + + tokens = append(tokens, tok) + s = s[len(m[0]):] + } + + return tokens, nil +} + +// parser computes an expression as it reads it, by precedence climbing: +// expr reads operands joined by operators of at least a given +// precedence, and hands the right operand of each to a deeper call that +// takes only the operators that bind tighter, so those are applied +// first. +type parser struct { + tokens []string +} + +// next removes and returns the next token, or "" at the end. +func (p *parser) next() string { + tok := p.peek() + if tok != "" { + p.tokens = p.tokens[1:] + } + + return tok +} + +// peek returns the next token, or "" at the end, and leaves it unread. +func (p *parser) peek() string { + if len(p.tokens) == 0 { + return "" + } + + return p.tokens[0] +} + +// expr reads and computes an expression whose binary operators all have +// at least minPrecedence. Operators of equal precedence group to the +// left, 8/2/2 is (8/2)/2, except ^, which groups to the right: 2^3^2 is +// 2^(3^2). +func (p *parser) expr(minPrecedence int) (constant.Value, error) { + x, err := p.operand() + if err != nil { + return nil, err + } + + for { + op := p.peek() + + prec := precedence(op) + if prec < minPrecedence { + return x, nil + } + + p.next() + + rightPrecedence := prec + 1 + if op == "^" { + rightPrecedence = prec + } + + y, err := p.expr(rightPrecedence) if err != nil { return nil, err } - return constant.UnaryOp(n.Op, x, 0), nil - case *ast.BinaryExpr: - return binary(n) - default: - return nil, ErrNotArithmetic + x, err = apply(x, op, y) + if err != nil { + return nil, err + } } } -func binary(n *ast.BinaryExpr) (constant.Value, error) { - switch n.Op { //nolint:exhaustive // every other operator is refused. - case token.ADD, token.SUB, token.MUL, token.QUO: +// operand reads a number, an expression in parentheses, or a sign and +// its operand. A sign binds more loosely than a power that follows it, +// so -2^2 is -(2^2), and 2^-1 is 2^(-1). +func (p *parser) operand() (constant.Value, error) { + switch tok := p.next(); tok { + case "+", "-": + x, err := p.expr(powerPrecedence) + if err != nil { + return nil, err + } + + if tok == "-" { + x = constant.UnaryOp(token.SUB, x, 0) + } + + return x, nil + case "(": + x, err := p.expr(sumPrecedence) + if err != nil { + return nil, err + } + + if p.next() != ")" { + return nil, ErrNotArithmetic + } + + return x, nil default: + return number(tok) + } +} + +// precedence returns the precedence of a binary operator, and 0 for any +// other token, which ends an expression. +func precedence(op string) int { + switch op { + case "+", "-": + return sumPrecedence + case "*", "/", "%": + return productPrecedence + case "^": + return powerPrecedence + default: + return 0 + } +} + +func number(tok string) (constant.Value, error) { + if !decimalLiteral.MatchString(tok) { return nil, ErrNotArithmetic } - x, err := eval(n.X) + // Read as FLOAT, which makes every literal decimal and a fraction + // (see exact): as INT, a leading zero would make it octal. + v := constant.MakeFromLiteral(tok, token.FLOAT, 0) + + // A literal such as 1e1300 or 1e-1233 is past bitLimit: see exact. + if !exact(v) { + return nil, ErrOutOfRange + } + + // One too small even to be held rounded, such as 1e-999999999, is + // read as 0. + mantissa, _, _ := strings.Cut(strings.ToLower(tok), "e") + if constant.Sign(v) == 0 && strings.ContainsAny(mantissa, "123456789") { + return nil, ErrOutOfRange + } + + return v, nil +} + +// apply computes x op y. +func apply(x constant.Value, op string, y constant.Value) (constant.Value, error) { + var ( + v constant.Value + err error + ) + + switch op { + case "+": + v = constant.BinaryOp(x, token.ADD, y) + case "-": + v = constant.BinaryOp(x, token.SUB, y) + case "*": + v = constant.BinaryOp(x, token.MUL, y) + case "/": + v, err = divide(x, y) + case "%": + v, err = modulo(x, y) + case "^": + v, err = power(x, y) + default: + err = ErrNotArithmetic + } + if err != nil { return nil, err } - y, err := eval(n.Y) - if err != nil { - return nil, err + if !exact(v) { + return nil, ErrOutOfRange } + return v, nil +} + +func divide(x, y constant.Value) (constant.Value, error) { // constant.BinaryOp panics on a zero divisor. - if n.Op == token.QUO && constant.Sign(y) == 0 { + if constant.Sign(y) == 0 { return nil, ErrDivisionByZero } // token.QUO divides exactly, integers included: 25/2 is 12.5. - v := constant.BinaryOp(x, n.Op, y) + return constant.BinaryOp(x, token.QUO, y), nil +} - // go/constant represents an overflow to infinity as Unknown. - if v.Kind() == constant.Unknown { - return nil, ErrTooLarge +// modulo computes x % y, whose result takes the sign of y, as in Python: +// -7 % 3 is 2 and 7 % -3 is -2. It is exact for decimals too: 7.5 % 2 +// is 1.5. +func modulo(x, y constant.Value) (constant.Value, error) { + q, err := divide(x, y) + if err != nil { + return nil, err + } + + // The fractional part of a rounded quotient, and so the remainder, + // would be wrong. + if !exact(q) { + return nil, ErrOutOfRange + } + + // x % y is y times the fractional part of x/y, which is at least 0 + // and less than 1, so the result has the sign of y. It is not + // computed as x minus y times the whole part of x/y: that product + // can be too large to hold exactly when the remainder is not. + // + // For x/y = n/d the fractional part is (n mod d)/d, exact because d + // is. token.REM truncates, leaving the sign of n; adding d brings a + // negative one into range. + n, d := constant.Num(q), constant.Denom(q) + + m := constant.BinaryOp(n, token.REM, d) + if constant.Sign(m) < 0 { + m = constant.BinaryOp(m, token.ADD, d) + } + + return constant.BinaryOp(y, token.MUL, constant.BinaryOp(m, token.QUO, d)), nil +} + +// power computes x^y. A negative x needs a whole y, and its sign is +// applied here: math.Pow would take it from the parity of y's float64 +// value, and every float64 from 2^53 up is even. +func power(x, y constant.Value) (constant.Value, error) { + // n is y if y is a whole number, and Unknown otherwise. + n := constant.ToInt(y) + + switch { + case constant.Sign(x) == 0 && constant.Sign(y) < 0: + return nil, ErrDivisionByZero + case constant.Sign(x) >= 0: + return nonNegativePower(x, y, n) + case n.Kind() != constant.Int: + return nil, ErrNoRealResult + } + + // x is negative and n whole: x^n is (-x)^n, negated if n is odd. + v, err := nonNegativePower(constant.UnaryOp(token.SUB, x, 0), y, n) + if err != nil { + return nil, err + } + + odd := constant.BinaryOp(n, token.AND, constant.MakeInt64(1)) + if constant.Sign(odd) != 0 { + v = constant.UnaryOp(token.SUB, v, 0) } return v, nil } -func literal(n *ast.BasicLit) (constant.Value, error) { - if n.Kind != token.INT && n.Kind != token.FLOAT { - return nil, ErrNotArithmetic +// nonNegativePower computes x^y for x of at least zero, and y not below +// zero if x is zero: exactly if y is a whole number n and go/constant +// holds the result exactly, otherwise in float64. +func nonNegativePower(x, y, n constant.Value) (constant.Value, error) { + e, ok := constant.Int64Val(n) + if ok && -maxExactExponent <= e && e <= maxExactExponent { + v := exactPower(x, e) + if exact(v) { + return v, nil + } } - if !decimalLiteral.MatchString(n.Value) { - return nil, ErrNotArithmetic + // y is above zero here if x is zero. + if constant.Sign(x) == 0 { + return x, nil } - // Read as FLOAT whatever the token says, which makes every literal - // decimal: as INT, a leading zero would make it octal. - v := constant.MakeFromLiteral(n.Value, token.FLOAT, 0) + xf, _ := constant.Float64Val(x) + yf, _ := constant.Float64Val(y) - // The syntax was checked above, so Unknown here means the exponent - // overflowed. - if v.Kind() == constant.Unknown { - return nil, ErrTooLarge + // x^y is (√x)^(2y). An x outside the normal range of a double, such + // as 2^1200, would lose digits as a double, or all of them, so square + // roots taken from its exact value bring it into that range first. As + // x is between 2^-4096 and 2^4096 (see exact), three at most are + // needed. + r, _ := constant.Val(x).(*big.Rat) + root := new(big.Float).SetPrec(floatPrecision).SetRat(r) + + for !normal(xf) { + root.Sqrt(root) + xf, _ = root.Float64() + yf *= 2 } - return v, nil + // x^y is not zero. If it is not a normal double, it has lost digits, + // or all of them. + f := math.Pow(xf, yf) + if !normal(f) { + return nil, ErrOutOfRange + } + + return constant.MakeFloat64(f), nil +} + +// exactPower computes x^e by repeated squaring. x is not zero if e is +// negative. It starts from 1 as a fraction, a Float to go/constant, so +// that x^0 is a fraction like every other number (see exact). Each +// step's numbers stay small: go/constant holds one whose numerator or +// denominator reaches 4096 bits as a 512-bit float. +func exactPower(x constant.Value, e int64) constant.Value { + one := constant.MakeFloat64(1) + result := one + + for n := max(e, -e); n > 0; n >>= 1 { + if n&1 == 1 { + result = constant.BinaryOp(result, token.MUL, x) + } + + x = constant.BinaryOp(x, token.MUL, x) + } + + if e < 0 { + result = constant.BinaryOp(one, token.QUO, result) + } + + return result +} + +// exact reports whether v is a fraction whose numerator and denominator +// are both below bitLimit bits, as every number here must be, so that +// each step of arithmetic stays small. go/constant never rounds an +// integer, however large, so every number is made a fraction: literals +// are read as FLOAT, and a power starts from the fraction 1. It rounds +// a fraction that grows past the limit, to a 512-bit float and past +// that float's range to Unknown, but not one it reads from a literal, +// such as 1e-1233, so the limit is checked here. +// +// A number that is not exact is refused wherever it appears: a sum can +// lose the answer entirely (7^1000*7^1000 + 5 - 7^1000*7^1000 would be +// 0), and a remainder, or whether an exponent is whole or odd, cannot be +// read from one. +func exact(v constant.Value) bool { + r, ok := constant.Val(v).(*big.Rat) + + return ok && r.Num().BitLen() < bitLimit && r.Denom().BitLen() < bitLimit +} + +// normal reports whether f is a normal double, finite and at least +// smallestNormal in magnitude: a number other than zero keeps all of a +// double's digits only as one. +func normal(f float64) bool { + abs := math.Abs(f) + + return abs >= smallestNormal && abs <= math.MaxFloat64 } // format writes a result for a person to read. A whole number of -// ordinary size is written exactly, digit for digit; anything else goes -// through float64, whose shortest round-trip form is free of the noise -// (0.30000000000000004) that printing a binary fraction to a fixed -// precision produces. -func format(v constant.Value) (string, error) { +// ordinary size is written exactly, digit for digit. Any other result in +// the normal range of a double goes through float64, whose shortest +// round-trip form is free of the noise (0.30000000000000004) that +// printing a binary fraction to a fixed precision produces. Past that +// range a double keeps fewer digits, or none (2^-1074 would be written +// 5e-324, and 2^1024 is infinite), so such a result is written from its +// exact value, to significantDigits. +func format(v constant.Value) string { f, _ := constant.Float64Val(v) - if math.IsInf(f, 0) || math.IsNaN(f) { - return "", ErrTooLarge + if constant.Sign(v) != 0 && !normal(f) { + // Every number here is exact: see exact. + r, _ := constant.Val(v).(*big.Rat) + + return new(big.Float).SetPrec(floatPrecision).SetRat(r).Text('g', significantDigits) } abs := math.Abs(f) if i := constant.ToInt(v); i.Kind() == constant.Int && abs < plainUpper { - return i.ExactString(), nil + return i.ExactString() } if abs >= plainUpper || abs < plainLower { - return strconv.FormatFloat(f, 'g', -1, 64), nil + return strconv.FormatFloat(f, 'g', -1, 64) } - return strconv.FormatFloat(f, 'f', -1, 64), nil + return strconv.FormatFloat(f, 'f', -1, 64) } diff --git a/internal/calc/calc_test.go b/internal/calc/calc_test.go index 9b91d97..e43bb5a 100644 --- a/internal/calc/calc_test.go +++ b/internal/calc/calc_test.go @@ -4,6 +4,7 @@ import ( "errors" "strings" "testing" + "time" "sneak.berlin/go/simplexcalc/internal/calc" ) @@ -13,7 +14,7 @@ import ( func TestEvaluate(t *testing.T) { t.Parallel() - cases := map[string]string{ + expectResults(t, map[string]string{ // The specification's own examples. "2 + 2": "4", "5 * 5/2": "12.5", @@ -51,7 +52,138 @@ func TestEvaluate(t *testing.T) { "1234567.5": "1234567.5", "-1 / 4": "-0.25", "1e300 * 1e8": "1e+308", - } + // Past the normal range of a double, written from the exact value + // to 17 significant digits, trailing zeros dropped. + "1e400": "1e+400", + "1e300 * 1e300": "1e+600", + "1 / 2e-400": "5e+399", + "-1e-310": "-1e-310", + "1 / 3e400": "3.3333333333333333e-401", + }) +} + +// TestEvaluatePowers: ^ and ** are one operator, binding tighter than +// * / % and a sign on its left, and grouping to the right. +func TestEvaluatePowers(t *testing.T) { + t.Parallel() + + expectResults(t, map[string]string{ + "2^3": "8", + "2**3": "8", + "2 ** 3 ^ 2": "512", + "2^3^2": "512", + "(2^3)^2": "64", + "-2^2": "-4", + "(-2)^2": "4", + "(-2)^3": "-8", + "(-2)^-3": "-0.125", + "2^-1": "0.5", + "2**-1": "0.5", + "-2^-2": "-0.25", + "2^-3^2": "0.001953125", + "2 * 3^2": "18", + "3^2 * 2": "18", + "2^3 / 2^2": "2", + "1 + 2^3 - 3^2": "0", + "010^2": "100", + "0.1^2": "0.01", + "2^100 - 2^100 + 1": "1", + "2^64": "18446744073709551616", + "2^100": "1.2676506002282294e+30", + "0^0": "1", + "0^3": "0", + "1.5^2": "2.25", + "2^0.5": "1.4142135623730951", + "-2^0.5": "-1.4142135623730951", + "4^0.5": "2", + "0^0.5": "0", + "2^1023": "8.98846567431158e+307", + "2^-1022": "2.2250738585072014e-308", + // Past 2^53 a float64 cannot tell odd from even. + "(-1)^(2^53 + 1)": "-1", + "(-1)^(10^30)": "1", + "(-1)^-9223372036854775808": "1", + // Whole powers beyond the range of a double, held exactly. + "2^-1400 * 2^1365 * 2^35": "1", + "0.3^900 * 10^470": "0.25652473503365386", + "2^1500 / 2^1000": "3.273390607896142e+150", + "(2^1200)/(2^1199)": "2", + "2^1200 % 7": "1", + // Results past the range of a double, written to 17 significant + // digits, up to the largest power of 2 under the 4096-bit limit. + "2^1200": "1.7218479456385751e+361", + "2**1200": "1.7218479456385751e+361", + "2^1024": "1.7976931348623159e+308", + "10^400": "1e+400", + "2^-1074": "4.9406564584124654e-324", + "2^-1200": "5.8077137562175032e-362", + "2^-1400": "3.6141491434385841e-422", + "0.5^1100": "7.3621518290228627e-332", + "1.5^2000": "1.5223626185737825e+352", + "(1/3)^-2000": "1.7478712517226516e+954", + "2^1200 - 2^1199": "8.6092397281928753e+360", + "-2^1201": "-3.4436958912771501e+361", + "(-2)^1201": "-3.4436958912771501e+361", + "2^2000 * 2^2000": "1.3182040934309431e+1204", + "10^1232": "1e+1232", + "2^4094": "2.6109722035328813e+1232", + // A power computed in float64 carries its rounding into the exact + // arithmetic after it, past the range of a double as within it. + "2^0.5 * 1e400": "1.4142135623730951e+400", + // A fractional power of an exact number outside the range of a + // double, taken from its exact value, up to the edges of that + // range. + "(2^1200)^0.5": "4.149515568880993e+180", + "(2^1024)^0.5": "1.3407807929942597e+154", + "(2^-1200)^0.5": "2.409919865102884e-181", + "1e400^0.5": "1e+200", + "1e-400^0.5": "1e-200", + "1e-310^0.5": "1e-155", + "(2^1200)^-0.5": "2.409919865102884e-181", + "(2^1200)^0.5 / 2^600": "1", + "1e400^-0.001": "0.39810717055349726", + "1e-400^0.001": "0.39810717055349726", + "(2^2047)^0.5": "1.2711610061536464e+308", + "(2^-2044)^0.5": "2.2250738585072014e-308", + }) +} + +// TestEvaluateModulo: % sits with * and /, left to right, and its result +// takes the sign of the divisor. +func TestEvaluateModulo(t *testing.T) { + t.Parallel() + + expectResults(t, map[string]string{ + "7 % 3": "1", + "-7 % 3": "2", + "7 % -3": "-2", + "-7 % -3": "-1", + "6 % 3": "0", + "-6 % 3": "0", + "7.5 % 2": "1.5", + "0.3 % 0.1": "0", + "-0.3 % 0.2": "0.1", + "10 % 4 * 3": "6", + "2 * 7 % 4": "2", + "1 + 7 % 3": "2", + "2^10 % 7": "2", + "10^400 % 7": "4", + "1e-30 % 1": "1e-30", + "-1e-30 % 1": "1", + "10 / 8 % 1": "0.25", + "(7 % 3)^2": "1", + "7 % (3 ^ 2)": "7", + // Both operands and their quotient are held exactly, but y times + // the whole part of x/y is too large to be. + "(5^860*3^630/7) % (5^860/2^998/2^998)": "0.5179219763783696", + // A whole number made from x^0, just below the 4096-bit limit. + "(3^0 + 3^0 + 3^0)^2583 % 10": "7", + }) +} + +// expectResults checks that each expression evaluates to its result. +func expectResults(t *testing.T, cases map[string]string) { + t.Helper() for in, want := range cases { t.Run(in, func(t *testing.T) { @@ -70,11 +202,11 @@ func TestEvaluate(t *testing.T) { } // TestEvaluateRefuses covers what must be answered with an error rather -// than a number, and never with a panic. +// than a number. func TestEvaluateRefuses(t *testing.T) { t.Parallel() - cases := map[string]error{ + expectErrors(t, map[string]error{ "": calc.ErrNotArithmetic, " ": calc.ErrNotArithmetic, "hello": calc.ErrNotArithmetic, @@ -89,20 +221,91 @@ func TestEvaluateRefuses(t *testing.T) { "2i * 2i": calc.ErrNotArithmetic, "0x10 + 1": calc.ErrNotArithmetic, "1_000 + 1": calc.ErrNotArithmetic, - "7 % 2": calc.ErrNotArithmetic, - "2 ^ 3": calc.ErrNotArithmetic, "1 << 10": calc.ErrNotArithmetic, "1 == 1": calc.ErrNotArithmetic, "!1": calc.ErrNotArithmetic, "func() int { return 1 }()": calc.ErrNotArithmetic, + "(1 + 2": calc.ErrNotArithmetic, + "1 + 2)": calc.ErrNotArithmetic, + "()": calc.ErrNotArithmetic, + "(2)(3)": calc.ErrNotArithmetic, + "2 ^": calc.ErrNotArithmetic, + "^ 2": calc.ErrNotArithmetic, + "2 ^^ 3": calc.ErrNotArithmetic, + "2 *** 3": calc.ErrNotArithmetic, + "2 * * 3": calc.ErrNotArithmetic, + "% 3": calc.ErrNotArithmetic, + "50%": calc.ErrNotArithmetic, + "2 × 3": calc.ErrNotArithmetic, "1 / 0": calc.ErrDivisionByZero, "1 / (2 - 2)": calc.ErrDivisionByZero, "5 / 0.0": calc.ErrDivisionByZero, - "1e400": calc.ErrTooLarge, - "1e300 * 1e300": calc.ErrTooLarge, - "1e999999999 * 1e999999999": calc.ErrTooLarge, - "1 / 1e-400": calc.ErrTooLarge, - } + "7 % 0": calc.ErrDivisionByZero, + "7.5 % (1 - 1)": calc.ErrDivisionByZero, + "0^-1": calc.ErrDivisionByZero, + "0^-0.5": calc.ErrDivisionByZero, + "(-2)^0.5": calc.ErrNoRealResult, + "(-8)^(1/3)": calc.ErrNoRealResult, + "(-1)^-0.5": calc.ErrNoRealResult, + }) +} + +// TestEvaluateOutOfRange: a number is held exactly, or computed in +// float64 as a normal double. Anything else is refused. +func TestEvaluateOutOfRange(t *testing.T) { + t.Parallel() + + expectErrors(t, map[string]error{ + // Just past the 4096-bit limit, which 2^4094 and 10^1232 are + // under, and far past it. + "2^4095": calc.ErrOutOfRange, + "-2^4095": calc.ErrOutOfRange, + "2^-4095": calc.ErrOutOfRange, + "10^1233": calc.ErrOutOfRange, + "2^4094 * 2": calc.ErrOutOfRange, + "1e999999999 * 1e999999999": calc.ErrOutOfRange, + "2^5000": calc.ErrOutOfRange, + "(-2)^5001": calc.ErrOutOfRange, + "0.5^-5000": calc.ErrOutOfRange, + // Powers computed in float64 whose result is not a normal double, + // and so has lost digits, or all of them, whatever the size of + // the base. + "2^-1073.5 * 2^1073": calc.ErrOutOfRange, + "2^1500.5": calc.ErrOutOfRange, + "(2^1200)^0.9": calc.ErrOutOfRange, + "1e-400^0.9": calc.ErrOutOfRange, + "(2^2048)^0.5": calc.ErrOutOfRange, + "(2^-2046)^0.5": calc.ErrOutOfRange, + "(0.5^1100)^4 / (0.5^1100)^4": calc.ErrOutOfRange, + "(1/3)^1e400": calc.ErrOutOfRange, + // go/constant holds numbers of this size rounded. A sum of them + // can lose the answer (this one would be 0), and so can a + // remainder or the sign of -1 to such a power. + "7^1000 * 7^1000 + 5 - 7^1000 * 7^1000": calc.ErrOutOfRange, + "7^1000 * 7^1000 / 7^1000 % 10": calc.ErrOutOfRange, + "(-1)^(3^1365 * 3^1365 / 3^1365)": calc.ErrOutOfRange, + "(-1)^1e1300": calc.ErrOutOfRange, + "1e-1300": calc.ErrOutOfRange, + "1e-1300 + 1": calc.ErrOutOfRange, + "1e-700 * 1e-700": calc.ErrOutOfRange, + "0.1^800 * 0.1^800": calc.ErrOutOfRange, + // Both operands are held exactly, but their quotient is not. + "3^1365 % 7^-1000": calc.ErrOutOfRange, + // The same limit for a whole number made from x^0, which + // go/constant would hold as an integer and never round, and for + // a literal it reads exactly as a fraction past the limit. + "(2^0 + 2^0)^4095 % 10": calc.ErrOutOfRange, + "1e-1233 * 0": calc.ErrOutOfRange, + // go/constant reads this literal as 0. + "1e-999999999": calc.ErrOutOfRange, + "1 / 1e-999999999": calc.ErrOutOfRange, + }) +} + +// expectErrors checks that each expression is refused with its error, +// and never with a panic. +func expectErrors(t *testing.T, cases map[string]error) { + t.Helper() for in, want := range cases { t.Run(in, func(t *testing.T) { @@ -116,6 +319,71 @@ func TestEvaluateRefuses(t *testing.T) { } } +// TestEvaluateBoundsWork: computed exactly, each of these powers would +// need more time and memory than any machine has. They must be answered +// at once. +func TestEvaluateBoundsWork(t *testing.T) { + t.Parallel() + + cases := []struct { + in string + want string + err error + }{ + {in: "9^9^9^9^9", err: calc.ErrOutOfRange}, + {in: "((9^999)^999)^999", err: calc.ErrOutOfRange}, + {in: "(3^2583)^4096", err: calc.ErrOutOfRange}, + {in: "1.0000001^99999", want: "1.01005006557947"}, + {in: "0.5^99999999999999999999", err: calc.ErrOutOfRange}, + {in: "2^-9223372036854775808", err: calc.ErrOutOfRange}, + {in: "(-1)^99999999999999999999", want: "-1"}, + // The longest tower that fits. + {in: strings.Repeat("9^", 127) + "9", err: calc.ErrOutOfRange}, + // The largest power of 3 computed exactly, as often as fits. + {in: "0" + strings.Repeat("*3^2583", 36), want: "0"}, + // Whole numbers made from x^0, through each operation. Held as + // integers, which go/constant never rounds, they would escape + // the 4096-bit limit: the first needs about 69 billion bits. + {in: "(((2^0+2^0)^4096)^4096)^4096", err: calc.ErrOutOfRange}, + {in: "(((0^0+0^0)^4096)^4096)^4096", err: calc.ErrOutOfRange}, + {in: "(((-2^0-2^0)^4096)^4096)^4096", err: calc.ErrOutOfRange}, + {in: "((2^0+2^0)^4000*(2^0+2^0)^4000)^4096", err: calc.ErrOutOfRange}, + {in: "((((2^0+2^0)/2^0)^4096)^4096)^4096", err: calc.ErrOutOfRange}, + {in: "((((2^0+2^0) % 3)^4096)^4096)^4096", err: calc.ErrOutOfRange}, + {in: "(((2^0+2^0)^4096)^4096)^4096 * 0", err: calc.ErrOutOfRange}, + // A fraction whose numerator and denominator are both just below + // the limit, and a literal whose exponent is too large to read. + {in: "(3^2583/5^1760)^4096", err: calc.ErrOutOfRange}, + {in: "1e99999999999999999999", err: calc.ErrOutOfRange}, + // Results just below the limit, written from their exact value. + {in: "2^4094", want: "2.6109722035328813e+1232"}, + {in: "3^2583", want: "2.5363018640659988e+1232"}, + {in: "2^-4094", want: "3.8299909843808741e-1233"}, + {in: "-1/3^2583", want: "-3.9427483540814775e-1233"}, + // Fractional powers of numbers just below the limit, whose bases + // take the most square roots to bring into the range of a double. + {in: "(2^-4094)^0.125", want: "8.869511863657883e-155"}, + {in: "(1/3^2583)^0.5", err: calc.ErrOutOfRange}, + } + + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + t.Parallel() + + start := time.Now() + got, err := calc.Evaluate(c.in) + + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("Evaluate(%q) took %v", c.in, elapsed) + } + + if !errors.Is(err, c.err) || got != c.want { + t.Errorf("Evaluate(%q) = %q, %v; want %q, %v", c.in, got, err, c.want, c.err) + } + }) + } +} + // TestEvaluateCapsInput: the length cap is what bounds the work a // message can cause, so it must hold exactly at the boundary. func TestEvaluateCapsInput(t *testing.T) { diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..3043bb1 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,37 @@ +// Package cli is the simplexcalc command tree: the run and version +// commands, and the exit status the program ends with. +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// appname is the name the program reports in its logs and its version. +const appname = "simplexcalc" + +// Main runs the command named on the command line and returns the exit +// status for main to pass to os.Exit. +// +// It leaves cobra's output streams at their defaults. cobra writes the +// usage and an unknown help topic to stderr, but SetOut moves them onto +// the stream it is given. +func Main(version string) int { + return execute(rootCmd(version)) +} + +// execute runs root and returns the exit status. It takes the command +// so that a test can set its arguments and output streams first. +func execute(root *cobra.Command) int { + err := root.Execute() + if err != nil { + // cobra has already printed the error; this only sets the exit + // status, which is what a supervisor and a shell script read. + _, _ = fmt.Fprintln(root.ErrOrStderr(), "exiting: "+err.Error()) + + return 1 + } + + return 0 +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..e27d5bd --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,51 @@ +package cli_test + +import ( + "bytes" + "runtime" + "strings" + "testing" + + "sneak.berlin/go/simplexcalc/internal/cli" +) + +func TestVersionPrintsVersion(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + + root := cli.RootCmd("1.2.3") + root.SetArgs([]string{"version"}) + root.SetOut(&stdout) + root.SetErr(&stderr) + + status := cli.Execute(root) + if status != 0 { + t.Fatalf("status = %d, want 0; stderr: %q", status, stderr.String()) + } + + want := "simplexcalc 1.2.3 " + runtime.GOOS + "/" + runtime.GOARCH + "\n" + if stdout.String() != want { + t.Errorf("stdout = %q, want %q", stdout.String(), want) + } +} + +func TestUnknownCommandFails(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + + root := cli.RootCmd("1.2.3") + root.SetArgs([]string{"frobnicate"}) + root.SetOut(&stdout) + root.SetErr(&stderr) + + status := cli.Execute(root) + if status == 0 { + t.Fatal("status = 0, want non-zero") + } + + if !strings.HasPrefix(stderr.String(), "exiting: ") { + t.Errorf("stderr = %q, want the error after \"exiting: \"", stderr.String()) + } +} diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go new file mode 100644 index 0000000..5d8b0fe --- /dev/null +++ b/internal/cli/export_test.go @@ -0,0 +1,11 @@ +package cli + +// RootCmd and Execute are rootCmd and execute, exported for the external +// test package, which builds the command tree itself so that it can +// supply the arguments and read the output. +// +//nolint:gochecknoglobals // test seams, not mutable state. +var ( + RootCmd = rootCmd + Execute = execute +) diff --git a/cmd/simplexcalc/root.go b/internal/cli/root.go similarity index 88% rename from cmd/simplexcalc/root.go rename to internal/cli/root.go index 42e4bc6..a312a22 100644 --- a/cmd/simplexcalc/root.go +++ b/internal/cli/root.go @@ -1,4 +1,4 @@ -package main +package cli import ( "fmt" @@ -11,7 +11,7 @@ import ( // environment (see internal/config), not from flags: the bot runs in a // container, where the environment is the interface, and one source of // truth means there is no precedence rule to get wrong. -func rootCmd() *cobra.Command { +func rootCmd(version string) *cobra.Command { root := &cobra.Command{ Use: appname, Short: appname + " — a SimpleX Chat bot that answers arithmetic", @@ -26,12 +26,12 @@ func rootCmd() *cobra.Command { SilenceErrors: true, } - root.AddCommand(runCmd(), versionCmd()) + root.AddCommand(runCmd(version), versionCmd(version)) return root } -func versionCmd() *cobra.Command { +func versionCmd(version string) *cobra.Command { return &cobra.Command{ Use: "version", Short: "print the version and exit", diff --git a/cmd/simplexcalc/run.go b/internal/cli/run.go similarity index 84% rename from cmd/simplexcalc/run.go rename to internal/cli/run.go index 5616b19..a863fd2 100644 --- a/cmd/simplexcalc/run.go +++ b/internal/cli/run.go @@ -1,4 +1,4 @@ -package main +package cli import ( "context" @@ -14,13 +14,13 @@ import ( "sneak.berlin/go/simplexcalc/internal/logger" ) -func runCmd() *cobra.Command { +func runCmd(version string) *cobra.Command { return &cobra.Command{ Use: "run", Short: "run the bot", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return run(cmd.Context()) + return run(cmd.Context(), version) }, } } @@ -28,7 +28,7 @@ func runCmd() *cobra.Command { // run reads the configuration and runs the bot until SIGINT or SIGTERM, // or until it fails. A failure is the process's exit status, so a // supervisor restarts it. -func run(ctx context.Context) error { +func run(ctx context.Context, version string) error { cfg, err := config.New() if err != nil { return fmt.Errorf("reading configuration: %w", err) @@ -45,7 +45,7 @@ func run(ctx context.Context) error { ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) defer stop() - err = bot.Run(ctx, log, cfg.DataDir) + err = bot.Run(ctx, log, cfg, bot.ChatPort) if err != nil { log.Error("stopped", "error", err) diff --git a/internal/config/config.go b/internal/config/config.go index 7e321d7..b670dd3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,8 +16,10 @@ package config import ( "errors" "fmt" + "os" "strconv" "strings" + "unicode/utf8" "github.com/spf13/viper" @@ -34,12 +36,23 @@ import ( // Environment variable names. Bare names, no prefix: this matches the // other services and keeps a compose file readable. const ( - EnvDataDir = "DATA_DIR" - EnvDebug = "DEBUG" + EnvDataDir = "DATA_DIR" + EnvDebug = "DEBUG" + EnvPort = "PORT" + EnvAPITokenFile = "API_TOKEN_FILE" //nolint:gosec // G101: a name, not a credential ) -// DefaultDataDir applies when DATA_DIR is absent. -const DefaultDataDir = "./data" +// Defaults, for the variables that are absent. +const ( + DefaultDataDir = "./data" + DefaultPort = 8080 +) + +// MinAPITokenLength is the fewest characters the API credential may +// have, not counting whitespace around it. +const MinAPITokenLength = 32 + +const maxPort = 65535 // ErrInvalidConfig is the sentinel every configuration failure wraps, // so callers can distinguish "the operator got it wrong" from "the @@ -51,10 +64,19 @@ var ErrInvalidConfig = errors.New("invalid configuration") // later, so there is exactly one moment at which configuration can be // wrong, and it is before anything starts. type Config struct { - // DataDir holds the SimpleX Chat database: the bot's profile, its - // address and its contacts. Losing it loses the address. + // DataDir holds the SimpleX Chat database, with the bot's profile, + // its address and its contacts, and the API's webhooks. Losing it + // loses the address. DataDir string Debug bool + + // Port is the API's TCP port. + Port int + + // APIToken is the credential every API request must carry, read + // from the file named by API_TOKEN_FILE. Empty when that is absent, + // and then the API refuses every request. Never log it. + APIToken string } // loader parses one environment into a Config, accumulating every @@ -109,6 +131,53 @@ func (l *loader) boolean(key string, def bool) bool { return b } +// port accepts a whole number from 1 to 65535 and refuses everything +// else. +func (l *loader) port(key string, def int) int { + s, ok := l.raw(key) + if !ok { + return def + } + + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > maxPort { + l.fail(key, s, "not a port (use a whole number from 1 to 65535)") + + return def + } + + return n +} + +// tokenFile returns the credential held in the file named by key, with +// the whitespace around it trimmed, or "" when key is absent. A file +// that cannot be read, or holds fewer than MinAPITokenLength +// characters, is a failure; the message names the file, never what it +// holds. +func (l *loader) tokenFile(key string) string { + path, ok := l.raw(key) + if !ok { + return "" + } + + b, err := os.ReadFile(path) //nolint:gosec // G304: the operator names the file. + if err != nil { + l.fail(key, path, "unreadable: "+err.Error()) + + return "" + } + + token := strings.TrimSpace(string(b)) + if utf8.RuneCountInString(token) < MinAPITokenLength { + l.fail(key, path, fmt.Sprintf("a file holding fewer than %d characters", + MinAPITokenLength)) + + return "" + } + + return token +} + // New parses and validates the environment. An error here aborts // startup before the chat client is launched, so there is no partially // configured running state to reason about. @@ -125,8 +194,10 @@ func load(v *viper.Viper) (*Config, error) { l := &loader{v: v} c := &Config{ - DataDir: l.str(EnvDataDir, DefaultDataDir), - Debug: l.boolean(EnvDebug, false), + DataDir: l.str(EnvDataDir, DefaultDataDir), + Debug: l.boolean(EnvDebug, false), + Port: l.port(EnvPort, DefaultPort), + APIToken: l.tokenFile(EnvAPITokenFile), } if len(l.errs) > 0 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3a3a378..3e1fb2f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,9 @@ package config_test import ( "errors" + "os" + "path/filepath" + "strings" "testing" "github.com/spf13/viper" @@ -26,8 +29,11 @@ func TestAbsentValuesTakeDefaults(t *testing.T) { t.Parallel() for name, kv := range map[string]map[string]string{ - "unset": nil, - "whitespace only": {config.EnvDataDir: " ", config.EnvDebug: " "}, + "unset": nil, + "whitespace only": { + config.EnvDataDir: " ", config.EnvDebug: " ", + config.EnvPort: " ", config.EnvAPITokenFile: "\t", + }, } { t.Run(name, func(t *testing.T) { t.Parallel() @@ -44,6 +50,14 @@ func TestAbsentValuesTakeDefaults(t *testing.T) { if c.Debug { t.Error("Debug must default off") } + + if c.Port != config.DefaultPort { + t.Errorf("Port = %d, want %d", c.Port, config.DefaultPort) + } + + if c.APIToken != "" { + t.Error("APIToken must default to none") + } }) } } @@ -94,3 +108,79 @@ func TestValidValuesAreUsed(t *testing.T) { t.Error("Debug = false, want true") } } + +// TestPort: PORT is a whole number from 1 to 65535, and anything else +// aborts. +func TestPort(t *testing.T) { + t.Parallel() + + for raw, want := range map[string]int{"1": 1, "8081": 8081, "65535": 65535} { + c, err := config.Load(env(map[string]string{config.EnvPort: raw})) + if err != nil { + t.Errorf("PORT=%q was rejected: %v", raw, err) + + continue + } + + if c.Port != want { + t.Errorf("PORT=%q: Port = %d, want %d", raw, c.Port, want) + } + } + + for _, raw := range []string{"0", "65536", "-1", "80.5", "8080x", "http"} { + _, err := config.Load(env(map[string]string{config.EnvPort: raw})) + if !errors.Is(err, config.ErrInvalidConfig) { + t.Errorf("PORT=%q: error = %v, want ErrInvalidConfig", raw, err) + } + } +} + +// TestAPITokenFile: the credential is the file's content without the +// whitespace around it. A file that cannot be read, or holds too short +// a credential, aborts, and the error never shows what the file holds. +func TestAPITokenFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + token := strings.Repeat("k", config.MinAPITokenLength) + short := strings.Repeat("s", config.MinAPITokenLength-1) + + for name, content := range map[string]string{ + "good": " " + token + "\n", + "short": "\n" + short + " \n", + } { + err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600) + if err != nil { + t.Fatal(err) + } + } + + load := func(name string) (*config.Config, error) { + return config.Load(env(map[string]string{ + config.EnvAPITokenFile: filepath.Join(dir, name), + })) + } + + c, err := load("good") + if err != nil { + t.Fatalf("a good file was rejected: %v", err) + } + + if c.APIToken != token { + t.Errorf("APIToken = %q, want %q", c.APIToken, token) + } + + _, err = load("missing") + if !errors.Is(err, config.ErrInvalidConfig) { + t.Errorf("a missing file: error = %v, want ErrInvalidConfig", err) + } + + _, err = load("short") + if !errors.Is(err, config.ErrInvalidConfig) { + t.Fatalf("a short credential: error = %v, want ErrInvalidConfig", err) + } + + if strings.Contains(err.Error(), short) { + t.Errorf("the error shows the file's content: %v", err) + } +} diff --git a/internal/simplex/client.go b/internal/simplex/client.go index e4bc309..c5b338f 100644 --- a/internal/simplex/client.go +++ b/internal/simplex/client.go @@ -14,6 +14,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strconv" "strings" "sync" @@ -21,15 +22,33 @@ import ( "github.com/gorilla/websocket" ) -// maxMessageSize bounds one message from the chat client. The largest -// thing it sends is a record carrying a contact's profile picture, well -// under this. +// maxMessageSize bounds one message from the chat client; a larger one +// ends the connection. The largest it sends are the pages of chat items +// ChatItems asks for, which chatItemsPage keeps under this. const maxMessageSize = 16 << 20 +// chatItemsPage is how many chat items ChatItems asks for at a time. An +// item repeats a message's text and spells out its formatting, which for +// text made of short mentions makes it 26 times as long as the text, and +// a contact's message can hold 64 KiB: one item can take 1.7 MiB. Five +// stay well under maxMessageSize. +const chatItemsPage = 5 + var ( // ErrClosed is returned by Command once the connection has ended. ErrClosed = errors.New("connection to the chat client closed") + // ErrNoContact is returned for a contact the user does not have. + ErrNoContact = errors.New("no such contact") + + // ErrContactNotReady is returned for sending to a contact who cannot + // receive messages: one who has deleted their chat with the user, or + // who has not finished connecting. + ErrContactNotReady = errors.New("the contact cannot receive messages") + + // ErrMessageTooLarge is returned for a message too large to send. + ErrMessageTooLarge = errors.New("the message is too large") + errUnexpected = errors.New("unexpected response") errCommand = errors.New("command failed") ) @@ -168,6 +187,55 @@ func (c *Client) SetAddressSettings( return c.command(ctx, cmd, TypeUserContactLinkUpdated, nil) } +// Contacts returns the user's contacts: everyone it has a direct chat +// with. +func (c *Client) Contacts(ctx context.Context, userID int64) ([]Contact, error) { + var r struct { + Contacts []Contact `json:"contacts"` + } + + err := c.command(ctx, cmdListContacts(userID), TypeContactsList, &r) + + return r.Contacts, err +} + +// ChatItems returns the last count items of the chat with a contact, +// oldest first, or all of them if the chat has fewer. +func (c *Client) ChatItems( + ctx context.Context, contactID int64, count int, +) ([]ChatItem, error) { + var ( + items []ChatItem + before int64 // 0 asks for the chat's last items + ) + + for len(items) < count { + page := min(count-len(items), chatItemsPage) + + //nolint:tagliatelle // the chat client's wire format. + var r struct { + Chat struct { + ChatItems []ChatItem `json:"chatItems"` + } `json:"chat"` + } + + err := c.command(ctx, cmdGetChat(contactID, before, page), TypeAPIChat, &r) + if err != nil { + return nil, err + } + + items = slices.Concat(r.Chat.ChatItems, items) + + if len(r.Chat.ChatItems) < page { + break + } + + before = items[0].Meta.ItemID + } + + return items, nil +} + // SendText sends a text message to a contact, as a reply to the message // quotedItemID (0 for none). It does not wait for the chat client to // accept it; a failure is logged when the client's answer arrives. @@ -184,6 +252,32 @@ func (c *Client) SendText(contactID, quotedItemID int64, text string) error { return c.write(id, cmd) } +// SendMessage sends a text message to a contact and returns it as the +// chat client recorded it. Unlike SendText, it waits for the chat +// client's answer, so an EventHandler must never call it. +func (c *Client) SendMessage( + ctx context.Context, contactID int64, text string, +) (ChatItem, error) { + cmd, err := cmdSendText(contactID, 0, text) + if err != nil { + return ChatItem{}, err + } + + var r NewChatItems + + err = c.command(ctx, cmd, TypeNewChatItems, &r) + if err != nil { + return ChatItem{}, err + } + + if len(r.ChatItems) != 1 { + return ChatItem{}, fmt.Errorf("%w to %q: %d chat items", + errUnexpected, cmdName(cmd), len(r.ChatItems)) + } + + return r.ChatItems[0].ChatItem, nil +} + // CommandError is a command the chat client refused. Type and Detail // are the discriminators of its chatError record, such as "errorStore" // and "userContactLinkNotFound". @@ -329,6 +423,8 @@ func (c *Client) dispatch(data []byte) { } } +// commandError returns the error in a chatCmdError record, marked with +// this package's error for the refusals that have one. func commandError(ev Event) error { var r cmdError @@ -347,7 +443,16 @@ func commandError(ev Event) error { } } - return e + switch e.Detail { + case "contactNotFound": + return fmt.Errorf("%w: %w", ErrNoContact, e) + case "contactNotReady": + return fmt.Errorf("%w: %w", ErrContactNotReady, e) + case "largeMsg": + return fmt.Errorf("%w: %w", ErrMessageTooLarge, e) + default: + return e + } } // cmdName is a command without its arguments, for error messages: the diff --git a/internal/simplex/client_test.go b/internal/simplex/client_test.go index feaa9f9..8515bbf 100644 --- a/internal/simplex/client_test.go +++ b/internal/simplex/client_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "slices" "strings" "sync" "testing" @@ -43,12 +44,25 @@ const ( contactConnected = `{"type":"contactConnected","user":{"userId":1}, "contact":{"contactId":3,"localDisplayName":"alice"}}` + + // Two contacts with the same display name, which the chat client + // tells apart by the local name it gives the second. The second has + // deleted its chat with the bot, and is still listed. + contactsList = `{"type":"contactsList","user":{"userId":1},"contacts":[ + {"contactId":3,"localDisplayName":"tester","profile":{"profileId":3, + "displayName":"tester","fullName":"","localAlias":""}, + "activeConn":{"connId":2,"connStatus":{"type":"ready"}}, + "contactUsed":true,"contactStatus":"active","chatDeleted":false}, + {"contactId":4,"localDisplayName":"tester_1","profile":{"profileId":4, + "displayName":"tester","fullName":"","localAlias":""}, + "activeConn":{"connId":3,"connStatus":{"type":"deleted"}}, + "contactUsed":true,"contactStatus":"deleted","chatDeleted":false}]}` ) // fakeChat stands in for the chat client's API. It answers each command -// with the record in replies under the command's first word, stays -// silent for a command it has no record for, and reports every command -// it receives on got. +// with the record in replies under the whole command or else under its +// first word, stays silent for a command it has no record for, and +// reports every command it receives on got. type fakeChat struct { replies map[string]string got chan string @@ -98,8 +112,13 @@ func (f *fakeChat) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.got <- cmd.Cmd - name, _, _ := strings.Cut(cmd.Cmd, " ") - if resp, ok := f.replies[name]; ok { + resp, ok := f.replies[cmd.Cmd] + if !ok { + name, _, _ := strings.Cut(cmd.Cmd, " ") + resp, ok = f.replies[name] + } + + if ok { f.send(cmd.CorrID, resp) } } @@ -220,6 +239,38 @@ func TestAddressSetup(t *testing.T) { } } +// TestContacts: the contacts of the given user come back with their +// ids, display names and whether they are deleted. +func TestContacts(t *testing.T) { + t.Parallel() + + f, url := newFakeChat(t, map[string]string{"/_contacts": contactsList}) + c, ctx := dial(t, url, nil) + + contacts, err := c.Contacts(ctx, 1) + if err != nil { + t.Fatalf("Contacts: %v", err) + } + + if got := f.next(t); got != "/_contacts 1" { + t.Errorf("command = %s, want /_contacts 1", got) + } + + tester := simplex.Profile{DisplayName: "tester"} + + want := []simplex.Contact{ + {ContactID: 3, Profile: tester, Status: "active"}, + {ContactID: 4, Profile: tester, Status: "deleted"}, + } + if !slices.Equal(contacts, want) { + t.Fatalf("Contacts = %+v\nwant %+v", contacts, want) + } + + if contacts[0].Deleted() || !contacts[1].Deleted() { + t.Error("Deleted must be false for contact 3 and true for contact 4") + } +} + // TestRefusedCommand: a command the chat client refuses is an error // that names the reason. func TestRefusedCommand(t *testing.T) { diff --git a/internal/simplex/messages_test.go b/internal/simplex/messages_test.go new file mode 100644 index 0000000..75c3086 --- /dev/null +++ b/internal/simplex/messages_test.go @@ -0,0 +1,214 @@ +package simplex_test + +import ( + "errors" + "slices" + "strconv" + "strings" + "testing" + "time" + + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +// Chat items as simplex-chat v7.0.2 sends them, oldest first: an event it +// records in a chat, the greeting the bot sent, a text, a formatted text +// and a picture a contact sent, and a text the bot sent. +const ( + itemEvent = `{"chatDir":{"type":"directRcv"},"meta":{"itemId":7, + "itemTs":"2026-09-29T03:13:46.521359978Z", + "itemText":"Audio/video calls: enabled","itemStatus":{"type":"rcvRead"}, + "createdAt":"2026-09-29T03:13:46.521359978Z"}, + "content":{"type":"rcvChatFeature","feature":"calls", + "enabled":{"forUser":true,"forContact":true}},"mentions":{},"reactions":[]}` + + itemGreeting = `{"chatDir":{"type":"directSnd"},"meta":{"itemId":8, + "itemTs":"2026-09-29T03:13:46.925072391Z","itemText":"hi", + "itemStatus":{"type":"sndRcvd","msgRcptStatus":"ok","sndProgress":"complete"}, + "createdAt":"2026-09-29T03:13:46.925072391Z"}, + "content":{"type":"sndMsgContent","msgContent":{"type":"text","text":"hi"}}, + "mentions":{},"reactions":[]}` + + itemText = `{"chatDir":{"type":"directRcv"},"meta":{"itemId":9, + "itemTs":"2026-09-29T03:14:34Z","itemText":"2 + 2", + "itemStatus":{"type":"rcvNew"},"itemSharedMsgId":"bCtLTUFwY3NCSFZIR002Ng==", + "createdAt":"2026-09-29T03:14:34.865730547Z"}, + "content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"2 + 2"}}, + "mentions":{},"reactions":[]}` + + itemFormatted = `{"chatDir":{"type":"directRcv"},"meta":{"itemId":10, + "itemTs":"2026-09-29T03:14:34Z","itemText":"*bold* and _italic_", + "itemStatus":{"type":"rcvNew"},"createdAt":"2026-09-29T03:14:34.935833284Z"}, + "content":{"type":"rcvMsgContent", + "msgContent":{"type":"text","text":"*bold* and _italic_"}},"mentions":{}, + "formattedText":[{"format":{"type":"bold"},"text":"bold"},{"text":" and "}, + {"format":{"type":"italic"},"text":"italic"}],"reactions":[]}` + + itemPicture = `{"chatDir":{"type":"directRcv"},"meta":{"itemId":11, + "itemTs":"2026-09-29T03:14:34Z","itemText":"a picture", + "itemStatus":{"type":"rcvNew"},"createdAt":"2026-09-29T03:14:35.020538268Z"}, + "content":{"type":"rcvMsgContent","msgContent":{"type":"image", + "text":"a picture","image":"data:image/jpg;base64,/9j/4AAQSkZJRgABAQ=="}}, + "mentions":{},"reactions":[]}` + + itemSent = `{"chatDir":{"type":"directSnd"},"meta":{"itemId":12, + "itemTs":"2026-09-29T03:14:43.519552587Z","itemText":"hello from the API", + "itemStatus":{"type":"sndNew"},"createdAt":"2026-09-29T03:14:43.519552587Z"}, + "content":{"type":"sndMsgContent", + "msgContent":{"type":"text","text":"hello from the API"}}, + "mentions":{},"reactions":[]}` +) + +// The chat client's answer to /_send, and its refusals: of a contact the +// bot does not have, of one who has deleted their chat with the bot, and +// of a text too long for one message. +const ( + sentItems = `{"type":"newChatItems","user":{"userId":1},"chatItems":[ + {"chatInfo":{"type":"direct","contact":{"contactId":3, + "localDisplayName":"tester","contactStatus":"active"}}, + "chatItem":` + itemSent + `}]}` + + contactNotFound = `{"type":"chatCmdError","chatError":{"type":"errorStore", + "storeError":{"type":"contactNotFound","contactId":4}}}` + + contactNotReady = `{"type":"chatCmdError","chatError":{"type":"error", + "errorType":{"type":"contactNotReady","contact":{"contactId":4, + "localDisplayName":"tester_1","contactStatus":"deleted", + "activeConn":{"connId":3,"connStatus":{"type":"deleted"}}}}}}` + + largeMsg = `{"type":"chatCmdError","chatError":{"type":"errorStore", + "storeError":{"type":"largeMsg"}}}` +) + +// apiChat is the chat client's answer to /_get chat, holding items. +func apiChat(items ...string) string { + return `{"type":"apiChat","user":{"userId":1},"chat":{"chatInfo":{ + "type":"direct","contact":{"contactId":3,"localDisplayName":"tester", + "contactStatus":"active"}},"chatItems":[` + strings.Join(items, ",") + `], + "chatStats":{"unreadCount":0,"minUnreadItemId":0,"unreadChat":false}}, + "navInfo":null}` +} + +// describe sums up a chat item as its id, content type, message if it +// has one, and time. +func describe(item simplex.ChatItem) string { + s := strconv.FormatInt(item.Meta.ItemID, 10) + " " + item.Content.Type + + if m := item.Content.MsgContent; m != nil { + s += " " + m.Type + " " + strconv.Quote(m.Text) + } + + return s + " " + item.Meta.ItemTs.Format(time.RFC3339Nano) +} + +// TestChatItems: the chat's last items come back oldest first, read five +// at a time from the newest backwards, and reading stops at the start +// of the chat. +func TestChatItems(t *testing.T) { + t.Parallel() + + f, url := newFakeChat(t, map[string]string{ + "/_get chat @3 count=5": apiChat( + itemGreeting, itemText, itemFormatted, itemPicture, itemSent), + "/_get chat @3 before=8 count=2": apiChat(itemEvent), + }) + c, ctx := dial(t, url, nil) + + items, err := c.ChatItems(ctx, 3, 7) + if err != nil { + t.Fatalf("ChatItems: %v", err) + } + + for _, want := range []string{ + "/_get chat @3 count=5", + "/_get chat @3 before=8 count=2", + } { + if got := f.next(t); got != want { + t.Errorf("command = %s, want %s", got, want) + } + } + + want := []string{ + `7 rcvChatFeature 2026-09-29T03:13:46.521359978Z`, + `8 sndMsgContent text "hi" 2026-09-29T03:13:46.925072391Z`, + `9 rcvMsgContent text "2 + 2" 2026-09-29T03:14:34Z`, + `10 rcvMsgContent text "*bold* and _italic_" 2026-09-29T03:14:34Z`, + `11 rcvMsgContent image "a picture" 2026-09-29T03:14:34Z`, + `12 sndMsgContent text "hello from the API" 2026-09-29T03:14:43.519552587Z`, + } + + got := make([]string, 0, len(items)) + for _, item := range items { + got = append(got, describe(item)) + } + + if !slices.Equal(got, want) { + t.Errorf("ChatItems =\n%s\nwant\n%s", + strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// TestSendMessage: a text goes out as /_send, and comes back as the chat +// client recorded it. +func TestSendMessage(t *testing.T) { + t.Parallel() + + f, url := newFakeChat(t, map[string]string{"/_send": sentItems}) + c, ctx := dial(t, url, nil) + + item, err := c.SendMessage(ctx, 3, "hello from the API") + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + + want := `/_send @3 json [{"msgContent":{"type":"text",` + + `"text":"hello from the API"},"mentions":{}}]` + if got := f.next(t); got != want { + t.Errorf("command = %s\nwant %s", got, want) + } + + wantItem := `12 sndMsgContent text "hello from the API" ` + + `2026-09-29T03:14:43.519552587Z` + if got := describe(item); got != wantItem { + t.Errorf("SendMessage = %s, want %s", got, wantItem) + } +} + +// TestRefusals: the chat client's refusals that callers answer for come +// back as this package's errors, still carrying the chat client's +// reason. +func TestRefusals(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + record string + read bool // ChatItems rather than SendMessage + want error + }{ + "reading, no such contact": {contactNotFound, true, simplex.ErrNoContact}, + "sending, no such contact": {contactNotFound, false, simplex.ErrNoContact}, + "sending, contact deleted": {contactNotReady, false, simplex.ErrContactNotReady}, + "sending, text too long": {largeMsg, false, simplex.ErrMessageTooLarge}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, url := newFakeChat(t, map[string]string{ + "/_get": tc.record, "/_send": tc.record, + }) + c, ctx := dial(t, url, nil) + + var err error + if tc.read { + _, err = c.ChatItems(ctx, 4, 20) + } else { + _, err = c.SendMessage(ctx, 4, "hi") + } + + var cerr *simplex.CommandError + if !errors.Is(err, tc.want) || !errors.As(err, &cerr) { + t.Errorf("error = %v, want %v with the chat client's reason", err, tc.want) + } + }) + } +} diff --git a/internal/simplex/protocol.go b/internal/simplex/protocol.go index 66bb80f..c3b6171 100644 --- a/internal/simplex/protocol.go +++ b/internal/simplex/protocol.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strconv" + "time" ) // Response and event types this package and the bot act on. The chat @@ -14,6 +15,8 @@ const ( TypeUserContactLink = "userContactLink" TypeUserContactLinkCreated = "userContactLinkCreated" TypeUserContactLinkUpdated = "userContactLinkUpdated" + TypeContactsList = "contactsList" + TypeAPIChat = "apiChat" TypeNewChatItems = "newChatItems" TypeContactConnected = "contactConnected" TypeChatCmdError = "chatCmdError" @@ -46,10 +49,14 @@ func (e Event) Decode(v any) error { type ( // User is the chat client's local user profile: the bot itself. User struct { - UserID int64 `json:"userId"` - Profile struct { - DisplayName string `json:"displayName"` - } `json:"profile"` + UserID int64 `json:"userId"` + Profile Profile `json:"profile"` + } + + // Profile is how the bot or a contact presents itself. Nothing + // makes a display name unique. + Profile struct { + DisplayName string `json:"displayName"` } // ConnLink is a SimpleX link. The short form is what people share; @@ -61,11 +68,14 @@ type ( // Contact is a person connected to the bot. Contact struct { - ContactID int64 `json:"contactId"` + ContactID int64 `json:"contactId"` + Profile Profile `json:"profile"` + Status string `json:"contactStatus"` } - // NewChatItems is the record of a newChatItems event: messages - // received, or sent from this profile elsewhere. + // NewChatItems is the record of a newChatItems event, messages + // received or sent from this profile elsewhere, and of the answer + // to sending a message. NewChatItems struct { ChatItems []AChatItem `json:"chatItems"` } @@ -75,25 +85,36 @@ type ( Contact Contact `json:"contact"` } - // AChatItem is one message together with the chat it belongs to. + // AChatItem is one chat item together with the chat it belongs to. AChatItem struct { ChatInfo struct { Type string `json:"type"` Contact *Contact `json:"contact,omitempty"` } `json:"chatInfo"` - ChatItem struct { - ChatDir tagged `json:"chatDir"` - Meta struct { - ItemID int64 `json:"itemId"` - } `json:"meta"` - Content struct { - Type string `json:"type"` - MsgContent *MsgContent `json:"msgContent,omitempty"` - } `json:"content"` - } `json:"chatItem"` + ChatItem ChatItem `json:"chatItem"` } - // MsgContent is a message body. Only "text" is sent or read here. + // ChatItem is one item in a chat: a message, or an event the chat + // client records there, such as the contact connecting. Content.Type + // tells them apart: "rcvMsgContent" and "sndMsgContent" are messages + // received and sent. + ChatItem struct { + ChatDir tagged `json:"chatDir"` + Meta struct { + ItemID int64 `json:"itemId"` + // ItemTs is when a received message reached the SimpleX + // relay, and when a sent one was sent. + ItemTs time.Time `json:"itemTs"` + } `json:"meta"` + Content struct { + Type string `json:"type"` + MsgContent *MsgContent `json:"msgContent,omitempty"` + } `json:"content"` + } + + // MsgContent is a message body: its type, such as "text", "image" + // or "file", and its text, which for anything but "text" is the + // caption. Only "text" is sent here. MsgContent struct { Type string `json:"type"` Text string `json:"text"` @@ -142,6 +163,13 @@ type ( } ) +// Deleted reports whether the contact is gone, as it is once the person +// deletes their chat with the bot. The chat client still lists such a +// contact, with its chat, but nothing more reaches them. +func (c Contact) Deleted() bool { + return c.Status != "active" +} + // Message is a text message a contact sent to the bot. type Message struct { ContactID int64 @@ -190,6 +218,24 @@ func cmdSetAddressSettings(userID int64, s AddressSettings) (string, error) { return "/_address_settings " + strconv.FormatInt(userID, 10) + " " + string(b), nil } +func cmdListContacts(userID int64) string { + return "/_contacts " + strconv.FormatInt(userID, 10) +} + +// cmdGetChat asks for the last count items of the chat with a contact, +// or, unless beforeItemID is 0, the last count before that item. It is +// missing from COMMANDS.md; its syntax is the client's parser's, in +// src/Simplex/Chat/Library/Commands.hs. +func cmdGetChat(contactID, beforeItemID int64, count int) string { + cmd := "/_get chat @" + strconv.FormatInt(contactID, 10) + + if beforeItemID != 0 { + cmd += " before=" + strconv.FormatInt(beforeItemID, 10) + } + + return cmd + " count=" + strconv.Itoa(count) +} + func cmdSendText(contactID, quotedItemID int64, text string) (string, error) { b, err := json.Marshal([]composedMessage{{ QuotedItemID: quotedItemID,