next: exponentiation, modulo, chat API and webhooks #10

Open
clawbot wants to merge 8 commits from next into main
34 changed files with 4940 additions and 216 deletions
+5 -3
View File
@@ -1,6 +1,6 @@
--- ---
title: Agent Guidance 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 This file is the single source of guidance for any automated agent
@@ -118,9 +118,11 @@ Do not weaken them.
## Layout ## 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/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/config/ viper-backed configuration; the abort-on-garbage rule
internal/logger/ log/slog, JSON always internal/logger/ log/slog, JSON always
internal/simplex/ the simplex-chat child process and its WebSocket API internal/simplex/ the simplex-chat child process and its WebSocket API
+7 -3
View File
@@ -109,9 +109,9 @@ WORKDIR /app
COPY --from=builder /build/bin/simplexcalc /app/simplexcalc COPY --from=builder /build/bin/simplexcalc /app/simplexcalc
# Data directory: the SimpleX database, which holds the bot's profile, # Data directory: the SimpleX database, which holds the bot's profile,
# its keys, its address and its contacts. Mount a volume over it; # its keys, its address and its contacts, and the API's webhooks. Mount
# without one, the bot gets a new address every time the container is # a volume over it; without one, the bot gets a new address and loses
# recreated. # its webhooks every time the container is recreated.
RUN mkdir -p /var/lib/simplexcalc && \ RUN mkdir -p /var/lib/simplexcalc && \
chown simplexcalc:simplexcalc /var/lib/simplexcalc chown simplexcalc:simplexcalc /var/lib/simplexcalc
@@ -119,4 +119,8 @@ USER simplexcalc
ENV DATA_DIR=/var/lib/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"] CMD ["/app/simplexcalc", "run"]
+356 -25
View File
@@ -5,24 +5,35 @@ SimpleX Chat network: it accepts every contact request and answers
arithmetic such as `2 + 2` with the result. arithmetic such as `2 + 2` with the result.
Send it `2 + 2` and it replies `4`; send `5 * 5/2` and it replies Send it `2 + 2` and it replies `4`; send `5 * 5/2` and it replies
`12.5`. It understands decimal numbers, `+ - * /`, unary minus and `12.5`. It understands decimal numbers, `+ - * /`, powers written `2^10`
parentheses, and computes exactly, so `0.1 + 0.2` is `0.3`. Anything or `2**10`, remainders written `7 % 3`, signs and parentheses, and
else gets a short explanation instead of a result. computes exactly, so `0.1 + 0.2` is `0.3`. Anything else gets a short
explanation instead of a result.
## Getting Started ## Getting Started
Build the image and run the bot, with its SimpleX profile on a named Build the image, write a random API credential onto a named volume, and
volume: run the bot with its SimpleX profile on the same volume:
```sh ```sh
git clone git@git.eeqj.de:clawbot/simplexcalc.git git clone git@git.eeqj.de:sneak/simplexcalc.git
cd simplexcalc cd simplexcalc
make docker 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 \ 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"' 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 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 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 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 is never quietly replaced by the default. Defaults apply only to
variables that are absent. variables that are absent.
- `DATA_DIR` — where the SimpleX database lives: the bot's profile, its - `DATA_DIR` — where the bot keeps what it must not lose: the SimpleX
keys, its address and its contacts. Default `./data`; the image sets 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`. `/var/lib/simplexcalc`.
- `DEBUG` — `true` or `false`, default `false`. `true` logs every event - `DEBUG` — `true` or `false`, default `false`. `true` logs every event
the chat client sends. 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 ## Entrypoints
@@ -142,8 +423,8 @@ container.
- **One process tree, one container.** `simplexcalc run` starts the - **One process tree, one container.** `simplexcalc run` starts the
SimpleX Chat command-line client, `simplex-chat`, as a child process, SimpleX Chat command-line client, `simplex-chat`, as a child process,
with its database under `$DATA_DIR/simplex` and its WebSocket API on 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 `127.0.0.1:5225`. That WebSocket has no authentication, which is why
never exposed outside the container. it is never exposed outside the container.
- **The protocol** (`internal/simplex`) is JSON over that WebSocket: a - **The protocol** (`internal/simplex`) is JSON over that WebSocket: a
command carries a correlation id, its response carries the same id, command carries a correlation id, its response carries the same id,
and anything without one is an event. Only the fields the bot reads 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 accept every contact request and to greet each new contact. The first
start creates the profile itself, a bot profile named `calc`. The start creates the profile itself, a bot profile named `calc`. The
address is logged in the `ready` line. 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, - **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 the bot sends back the result, as a reply quoting the message. Group
messages, files and the bot's own messages are ignored. messages, files and the bot's own messages are ignored.
- **Arithmetic** (`internal/calc`): the text is parsed as a Go - **Arithmetic** (`internal/calc`): a small parser of its own reads
expression with `go/parser`, and only numbers, `+ - * /`, unary signs numbers, `+ - * / % ^`, signs and parentheses, and refuses anything
and parentheses are evaluated; anything else in the syntax tree is else. `go/constant` computes with exact rationals. `^`, also written
refused. `go/constant` computes with exact rationals. Numbers are read `**`, is a power: it binds tighter than `*`, `/`, `%` and a sign on
as decimal, so `010` is ten. Input over 256 bytes is refused, so a 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 message cannot make the bot do unbounded work. Whole numbers below
10<sup>21</sup> are written exactly; other results in the shortest 10<sup>21</sup> are written exactly. Other results inside the normal
form that reads back as the same double, in exponent notation from range of a double, about 2.2e-308 to 1.8e308 in magnitude, where a
10<sup>21</sup> up and below 10<sup>-6</sup>. A result beyond the double keeps all its digits, are written in the shortest form that
range of a double is refused as too large. reads back as the same double, in exponent notation from
10<sup>21</sup> up and below 10<sup>-6</sup>. 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 - **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 it drops, the bot exits with an error and the container's restart
policy starts both again. `SIGTERM` stops the bot, which stops the policy starts both again. `SIGTERM` stops the bot, which stops the
@@ -180,11 +508,14 @@ container.
## Operating it ## Operating it
**Backup.** Everything durable is the SimpleX database on the volume: **Backup.** Everything durable is on the volume: the SimpleX database,
`simplex_chat.db` and `simplex_agent.db`. Stop the container before `simplex_chat.db` and `simplex_agent.db`; the API credential,
copying them, since a copy taken from under the running client can be `api-token`; and the webhooks, `webhooks.json`. Stop the container
inconsistent. The database holds the bot's keys, so a copy lets its before copying the database, since a copy taken from under the running
holder answer as the bot; keep it as private as the running instance. 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 **Upgrade.** Rebuild the image and recreate the container with the same
volume. The chat client migrates its database on start. A newer volume. The chat client migrates its database on start. A newer
+3 -11
View File
@@ -2,12 +2,10 @@
package main package main
import ( import (
"fmt"
"os" "os"
)
// appname is the name the program reports in its logs and its version. "sneak.berlin/go/simplexcalc/internal/cli"
const appname = "simplexcalc" )
// version is injected at build time with -ldflags "-X main.version=...". // version is injected at build time with -ldflags "-X main.version=...".
// The Dockerfile passes VERSION; a `go build` without it says "dev", // The Dockerfile passes VERSION; a `go build` without it says "dev",
@@ -16,11 +14,5 @@ const appname = "simplexcalc"
var version = "dev" var version = "dev"
func main() { func main() {
err := rootCmd().Execute() os.Exit(cli.Main(version))
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)
}
} }
+17
View File
@@ -27,6 +27,23 @@ with no deprecation warning.
# Completed Steps # 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 - 2026-09-26 Seeded from `go-template-repo` (renamed, its HTTP service
removed) and built the bot: `internal/calc` for the arithmetic, removed) and built the bot: `internal/calc` for the arithmetic,
`internal/simplex` for the chat client process and its WebSocket API, `internal/simplex` for the chat client process and its WebSocket API,
+1
View File
@@ -3,6 +3,7 @@ module sneak.berlin/go/simplexcalc
go 1.25.0 go 1.25.0
require ( require (
github.com/go-chi/chi/v5 v5.3.2
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
+2
View File
@@ -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/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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+243
View File
@@ -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))
})
}
+296
View File
@@ -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)
}
}
}
+97
View File
@@ -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
}
+64
View File
@@ -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)
}
}
+187
View File
@@ -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
}
+328
View File
@@ -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)
}
}
+9
View File
@@ -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
)
+188
View File
@@ -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
}
+367
View File
@@ -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)
}
})
}
}
+306
View File
@@ -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)
}
}
+510
View File
@@ -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)
}
}
+106 -31
View File
@@ -8,12 +8,15 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time" "time"
"sneak.berlin/go/simplexcalc/internal/api"
"sneak.berlin/go/simplexcalc/internal/calc" "sneak.berlin/go/simplexcalc/internal/calc"
"sneak.berlin/go/simplexcalc/internal/config"
"sneak.berlin/go/simplexcalc/internal/simplex" "sneak.berlin/go/simplexcalc/internal/simplex"
) )
@@ -22,14 +25,14 @@ import (
const DisplayName = "calc" const DisplayName = "calc"
// Welcome is sent to everyone whose contact request the bot accepts. // 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." "and I will reply with the result."
const ( // ChatPort is where the chat client serves its API, on localhost inside
// chatPort is where the chat client serves its API, on localhost // the bot's own container.
// inside the bot's own container. const ChatPort = 5225
chatPort = 5225
const (
// connectTimeout bounds the wait for a freshly started chat client // connectTimeout bounds the wait for a freshly started chat client
// to open its API, which includes creating or migrating the // to open its API, which includes creating or migrating the
// database. // database.
@@ -42,26 +45,49 @@ const (
// retryInterval paces the connection attempts. // retryInterval paces the connection attempts.
retryInterval = 250 * time.Millisecond 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 dataDirMode = 0o700
) )
var errExited = errors.New("simplex-chat exited") var errExited = errors.New("simplex-chat exited")
// Run starts the chat client with its database in dataDir, connects to // Run reads the webhooks kept in cfg.DataDir, starts the chat client with
// it, sets up the bot's address, and answers messages until ctx is // its database there, serving its API on localhost at chatPort, connects
// cancelled — which is a clean stop and returns nil — or until the chat // to it, sets up the bot's address, then answers messages, posts them to
// client or the connection to it fails, which returns the error. // the webhooks and serves the bot's API until ctx is cancelled — which is
func Run(ctx context.Context, log *slog.Logger, dataDir string) error { // a clean stop and returns nil — or until the chat client, the connection
err := os.MkdirAll(dataDir, dataDirMode) // 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 { if err != nil {
return fmt.Errorf("creating data directory: %w", err) 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 // Cancelling this stops the chat client; the deferred wait makes
// Run return only once it has exited, whatever path Run takes. // 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) DisplayName, chatPort)
if err != nil { if err != nil {
stopCLI() stopCLI()
@@ -74,18 +100,39 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
<-cli.Done() <-cli.Done()
}() }()
client, err := connect(ctx, log, cli) client, err := connect(ctx, log, cli, chatPort, handle(log, deliveries))
if err != nil { if err != nil {
return err return err
} }
defer func() { _ = client.Close() }() defer func() { _ = client.Close() }()
err = setUp(ctx, log, client) user, err := setUp(ctx, log, client)
if err != nil { if err != nil {
return err 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 { select {
case <-ctx.Done(): case <-ctx.Done():
return nil 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()) return fmt.Errorf("%w: %w", simplex.ErrClosed, client.Err())
case <-cli.Done(): case <-cli.Done():
return fmt.Errorf("%w: %w", errExited, cli.Err()) 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( 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) { ) (*simplex.Client, error) {
ctx, cancel := context.WithTimeout(ctx, connectTimeout) ctx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel() defer cancel()
@@ -106,7 +172,7 @@ func connect(
url := "ws://127.0.0.1:" + strconv.Itoa(chatPort) url := "ws://127.0.0.1:" + strconv.Itoa(chatPort)
for { for {
client, err := simplex.Dial(ctx, url, log, handle(log)) client, err := simplex.Dial(ctx, url, log, onEvent)
if err == nil { if err == nil {
return client, nil return client, nil
} }
@@ -125,19 +191,22 @@ func connect(
// setUp gives the bot a long-term contact address, creating it on the // 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 // 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 // each new contact. The settings are written on every start, so an
// address whose settings were changed by hand is put right. // address whose settings were changed by hand is put right. It returns
func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error { // 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) ctx, cancel := context.WithTimeout(ctx, setupTimeout)
defer cancel() defer cancel()
user, err := client.ActiveUser(ctx) user, err := client.ActiveUser(ctx)
if err != nil { 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) link, ok, err := client.Address(ctx, user.UserID)
if err != nil { 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 { 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) link, err = client.CreateAddress(ctx, user.UserID)
if err != nil { 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}, AutoReply: &simplex.MsgContent{Type: "text", Text: Welcome},
}) })
if err != nil { 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", log.Info("ready",
@@ -163,11 +232,12 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error
"full_address", link.FullLink, "full_address", link.FullLink,
) )
return nil return user, nil
} }
// handle answers each text message a contact sends. // handle answers each text message a contact sends, and hands every
func handle(log *slog.Logger) simplex.EventHandler { // 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) { return func(c *simplex.Client, ev simplex.Event) {
switch ev.Type { switch ev.Type {
case simplex.TypeNewChatItems: case simplex.TypeNewChatItems:
@@ -181,6 +251,9 @@ func handle(log *slog.Logger) simplex.EventHandler {
} }
for _, item := range r.ChatItems { for _, item := range r.ChatItems {
// Never waits, so no webhook holds up the replies.
deliveries.Add(item)
msg, ok := item.Message() msg, ok := item.Message()
if !ok { if !ok {
continue continue
@@ -221,10 +294,12 @@ func Reply(text string) string {
calc.MaxInputLength) calc.MaxInputLength)
case errors.Is(err, calc.ErrDivisionByZero): case errors.Is(err, calc.ErrDivisionByZero):
return "I cannot divide by zero." return "I cannot divide by zero."
case errors.Is(err, calc.ErrTooLarge): case errors.Is(err, calc.ErrOutOfRange):
return "The result is too large for me." 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: default:
return "I only understand arithmetic: numbers, + - * / and " + return "I only understand arithmetic: numbers, + - * /, ^ for a power, " +
"parentheses, such as 5 * 5/2." "% for a remainder, and parentheses, such as 5 * 5/2 or 2^10."
} }
} }
+6 -1
View File
@@ -16,6 +16,9 @@ func TestReply(t *testing.T) {
for in, want := range map[string]string{ for in, want := range map[string]string{
"2 + 2": "4", "2 + 2": "4",
"5 * 5/2": "12.5", "5 * 5/2": "12.5",
"2^10": "1024",
"7 % 3": "1",
"2^1200": "1.7218479456385751e+361",
} { } {
if got := bot.Reply(in); got != want { if got := bot.Reply(in); got != want {
t.Errorf("Reply(%q) = %q, want %q", in, got, want) t.Errorf("Reply(%q) = %q, want %q", in, got, want)
@@ -25,7 +28,9 @@ func TestReply(t *testing.T) {
for in, want := range map[string]string{ for in, want := range map[string]string{
"hello": "I only understand arithmetic", "hello": "I only understand arithmetic",
"1 / 0": "I cannot divide by zero.", "1 / 0": "I cannot divide by zero.",
"1e400": "The result is too large for me.", "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", strings.Repeat("1+", calc.MaxInputLength) + "1": "That is too long for me",
} { } {
if got := bot.Reply(in); !strings.HasPrefix(got, want) { if got := bot.Reply(in); !strings.HasPrefix(got, want) {
+423
View File
@@ -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):
}
}
}
+420 -87
View File
@@ -1,29 +1,45 @@
// Package calc evaluates the arithmetic people send the bot: decimal // 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, // The expression is read by a small parser of its own, because Go's
// which does exact rational arithmetic: 5 * 5/2 is exactly 12.5, and // grammar has no power operator (^ is XOR there), and computed by
// 0.1 + 0.2 is exactly 0.3, so a result carries no binary floating // go/constant, which does exact rational arithmetic: 5 * 5/2 is exactly
// point noise until the moment it is formatted. // 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 package calc
import ( import (
"errors" "errors"
"go/ast"
"go/constant" "go/constant"
"go/parser"
"go/token" "go/token"
"math" "math"
"math/big"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
) )
// MaxInputLength caps an expression, in bytes, so a message cannot make // MaxInputLength caps an expression, in bytes. With maxExactExponent,
// the bot do unbounded work. Every operation's cost grows with the size // which caps a power computed exactly, and bitLimit, which caps every
// of its operands, and the operands can only grow with the input. // number, it keeps a message from making the bot do unbounded work.
const MaxInputLength = 256 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 // Results of magnitude plainUpper or more are written in exponent form
// (1e+21 rather than twenty-two digits), and so are fractions smaller // (1e+21 rather than twenty-two digits), and so are fractions smaller
// than plainLower (1e-07 rather than 0.0000001). // than plainLower (1e-07 rather than 0.0000001).
@@ -32,150 +48,467 @@ const (
plainLower = 1e-6 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. // Errors returned by Evaluate. The bot turns each into a reply.
var ( var (
ErrTooLong = errors.New("expression too long") ErrTooLong = errors.New("expression too long")
ErrNotArithmetic = errors.New("not an arithmetic expression") ErrNotArithmetic = errors.New("not an arithmetic expression")
ErrDivisionByZero = errors.New("division by zero") 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 // decimal is the only number syntax accepted. Go's own literal syntax is
// syntax is wider, and parts of it are traps for someone typing // wider, and parts of it are traps for someone typing arithmetic: 010 is
// arithmetic: 010 is octal 8, and 0x10, 1_000 and 1i are not what a // octal 8, and 0x10, 1_000 and 1i are not what a calculator user means
// calculator user means by a number. // by a number. Here the x, _ or i matches no token and is refused.
var decimalLiteral = regexp.MustCompile( const decimal = `([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?`
`^([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 // Evaluate computes an arithmetic expression and returns its result as
// text: whole numbers without a decimal point, fractions in the // text, written as format describes.
// shortest form that reads back as the same float64.
func Evaluate(input string) (string, error) { func Evaluate(input string) (string, error) {
s := strings.TrimSpace(input) s := strings.TrimSpace(input)
if len(s) > MaxInputLength { if len(s) > MaxInputLength {
return "", ErrTooLong return "", ErrTooLong
} }
if s == "" { tokens, err := tokenize(s)
return "", ErrNotArithmetic
}
expr, err := parser.ParseExpr(s)
if err != nil {
return "", ErrNotArithmetic
}
v, err := eval(expr)
if err != nil { if err != nil {
return "", err return "", err
} }
return format(v) p := parser{tokens: tokens}
v, err := p.expr(sumPrecedence)
if err != nil {
return "", err
} }
// eval walks the syntax tree, allowing only the node types and if p.next() != "" {
// operators of arithmetic. Anything else — identifiers, calls, strings, return "", ErrNotArithmetic
// shifts, comparisons — is refused, not evaluated. }
func eval(e ast.Expr) (constant.Value, error) {
switch n := e.(type) { return format(v), nil
case *ast.BasicLit: }
return literal(n)
case *ast.ParenExpr: // tokenize splits an expression into operators, parentheses and
return eval(n.X) // numbers, and refuses anything else. ** is returned as ^.
case *ast.UnaryExpr: func tokenize(s string) ([]string, error) {
if n.Op != token.ADD && n.Op != token.SUB { var tokens []string
for strings.TrimSpace(s) != "" {
m := nextToken.FindStringSubmatch(s)
if m == nil {
return nil, ErrNotArithmetic 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 { if err != nil {
return nil, err return nil, err
} }
return constant.UnaryOp(n.Op, x, 0), nil for {
case *ast.BinaryExpr: op := p.peek()
return binary(n)
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
}
x, err = apply(x, op, y)
if err != nil {
return nil, err
}
}
}
// 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: default:
return nil, ErrNotArithmetic return number(tok)
} }
} }
func binary(n *ast.BinaryExpr) (constant.Value, error) { // precedence returns the precedence of a binary operator, and 0 for any
switch n.Op { //nolint:exhaustive // every other operator is refused. // other token, which ends an expression.
case token.ADD, token.SUB, token.MUL, token.QUO: func precedence(op string) int {
switch op {
case "+", "-":
return sumPrecedence
case "*", "/", "%":
return productPrecedence
case "^":
return powerPrecedence
default: default:
return 0
}
}
func number(tok string) (constant.Value, error) {
if !decimalLiteral.MatchString(tok) {
return nil, ErrNotArithmetic 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 { if err != nil {
return nil, err return nil, err
} }
y, err := eval(n.Y) if !exact(v) {
if err != nil { return nil, ErrOutOfRange
return nil, err
} }
return v, nil
}
func divide(x, y constant.Value) (constant.Value, error) {
// constant.BinaryOp panics on a zero divisor. // constant.BinaryOp panics on a zero divisor.
if n.Op == token.QUO && constant.Sign(y) == 0 { if constant.Sign(y) == 0 {
return nil, ErrDivisionByZero return nil, ErrDivisionByZero
} }
// token.QUO divides exactly, integers included: 25/2 is 12.5. // 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. // modulo computes x % y, whose result takes the sign of y, as in Python:
if v.Kind() == constant.Unknown { // -7 % 3 is 2 and 7 % -3 is -2. It is exact for decimals too: 7.5 % 2
return nil, ErrTooLarge // 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 return v, nil
} }
func literal(n *ast.BasicLit) (constant.Value, error) { // nonNegativePower computes x^y for x of at least zero, and y not below
if n.Kind != token.INT && n.Kind != token.FLOAT { // zero if x is zero: exactly if y is a whole number n and go/constant
return nil, ErrNotArithmetic // holds the result exactly, otherwise in float64.
} func nonNegativePower(x, y, n constant.Value) (constant.Value, error) {
e, ok := constant.Int64Val(n)
if !decimalLiteral.MatchString(n.Value) { if ok && -maxExactExponent <= e && e <= maxExactExponent {
return nil, ErrNotArithmetic v := exactPower(x, e)
} if exact(v) {
// 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)
// The syntax was checked above, so Unknown here means the exponent
// overflowed.
if v.Kind() == constant.Unknown {
return nil, ErrTooLarge
}
return v, nil return v, nil
} }
}
// y is above zero here if x is zero.
if constant.Sign(x) == 0 {
return x, nil
}
xf, _ := constant.Float64Val(x)
yf, _ := constant.Float64Val(y)
// 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
}
// 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 // format writes a result for a person to read. A whole number of
// ordinary size is written exactly, digit for digit; anything else goes // ordinary size is written exactly, digit for digit. Any other result in
// through float64, whose shortest round-trip form is free of the noise // the normal range of a double goes through float64, whose shortest
// (0.30000000000000004) that printing a binary fraction to a fixed // round-trip form is free of the noise (0.30000000000000004) that
// precision produces. // printing a binary fraction to a fixed precision produces. Past that
func format(v constant.Value) (string, error) { // 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) f, _ := constant.Float64Val(v)
if math.IsInf(f, 0) || math.IsNaN(f) { if constant.Sign(v) != 0 && !normal(f) {
return "", ErrTooLarge // 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) abs := math.Abs(f)
if i := constant.ToInt(v); i.Kind() == constant.Int && abs < plainUpper { if i := constant.ToInt(v); i.Kind() == constant.Int && abs < plainUpper {
return i.ExactString(), nil return i.ExactString()
} }
if abs >= plainUpper || abs < plainLower { 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)
} }
+277 -9
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"strings" "strings"
"testing" "testing"
"time"
"sneak.berlin/go/simplexcalc/internal/calc" "sneak.berlin/go/simplexcalc/internal/calc"
) )
@@ -13,7 +14,7 @@ import (
func TestEvaluate(t *testing.T) { func TestEvaluate(t *testing.T) {
t.Parallel() t.Parallel()
cases := map[string]string{ expectResults(t, map[string]string{
// The specification's own examples. // The specification's own examples.
"2 + 2": "4", "2 + 2": "4",
"5 * 5/2": "12.5", "5 * 5/2": "12.5",
@@ -51,8 +52,139 @@ func TestEvaluate(t *testing.T) {
"1234567.5": "1234567.5", "1234567.5": "1234567.5",
"-1 / 4": "-0.25", "-1 / 4": "-0.25",
"1e300 * 1e8": "1e+308", "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 { for in, want := range cases {
t.Run(in, func(t *testing.T) { t.Run(in, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -70,11 +202,11 @@ func TestEvaluate(t *testing.T) {
} }
// TestEvaluateRefuses covers what must be answered with an error rather // 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) { func TestEvaluateRefuses(t *testing.T) {
t.Parallel() t.Parallel()
cases := map[string]error{ expectErrors(t, map[string]error{
"": calc.ErrNotArithmetic, "": calc.ErrNotArithmetic,
" ": calc.ErrNotArithmetic, " ": calc.ErrNotArithmetic,
"hello": calc.ErrNotArithmetic, "hello": calc.ErrNotArithmetic,
@@ -89,21 +221,92 @@ func TestEvaluateRefuses(t *testing.T) {
"2i * 2i": calc.ErrNotArithmetic, "2i * 2i": calc.ErrNotArithmetic,
"0x10 + 1": calc.ErrNotArithmetic, "0x10 + 1": calc.ErrNotArithmetic,
"1_000 + 1": calc.ErrNotArithmetic, "1_000 + 1": calc.ErrNotArithmetic,
"7 % 2": calc.ErrNotArithmetic,
"2 ^ 3": calc.ErrNotArithmetic,
"1 << 10": calc.ErrNotArithmetic, "1 << 10": calc.ErrNotArithmetic,
"1 == 1": calc.ErrNotArithmetic, "1 == 1": calc.ErrNotArithmetic,
"!1": calc.ErrNotArithmetic, "!1": calc.ErrNotArithmetic,
"func() int { return 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 / 0": calc.ErrDivisionByZero,
"1 / (2 - 2)": calc.ErrDivisionByZero, "1 / (2 - 2)": calc.ErrDivisionByZero,
"5 / 0.0": calc.ErrDivisionByZero, "5 / 0.0": calc.ErrDivisionByZero,
"1e400": calc.ErrTooLarge, "7 % 0": calc.ErrDivisionByZero,
"1e300 * 1e300": calc.ErrTooLarge, "7.5 % (1 - 1)": calc.ErrDivisionByZero,
"1e999999999 * 1e999999999": calc.ErrTooLarge, "0^-1": calc.ErrDivisionByZero,
"1 / 1e-400": calc.ErrTooLarge, "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 { for in, want := range cases {
t.Run(in, func(t *testing.T) { t.Run(in, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -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 // TestEvaluateCapsInput: the length cap is what bounds the work a
// message can cause, so it must hold exactly at the boundary. // message can cause, so it must hold exactly at the boundary.
func TestEvaluateCapsInput(t *testing.T) { func TestEvaluateCapsInput(t *testing.T) {
+37
View File
@@ -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
}
+51
View File
@@ -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())
}
}
+11
View File
@@ -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
)
@@ -1,4 +1,4 @@
package main package cli
import ( import (
"fmt" "fmt"
@@ -11,7 +11,7 @@ import (
// environment (see internal/config), not from flags: the bot runs in a // environment (see internal/config), not from flags: the bot runs in a
// container, where the environment is the interface, and one source of // container, where the environment is the interface, and one source of
// truth means there is no precedence rule to get wrong. // truth means there is no precedence rule to get wrong.
func rootCmd() *cobra.Command { func rootCmd(version string) *cobra.Command {
root := &cobra.Command{ root := &cobra.Command{
Use: appname, Use: appname,
Short: appname + " — a SimpleX Chat bot that answers arithmetic", Short: appname + " — a SimpleX Chat bot that answers arithmetic",
@@ -26,12 +26,12 @@ func rootCmd() *cobra.Command {
SilenceErrors: true, SilenceErrors: true,
} }
root.AddCommand(runCmd(), versionCmd()) root.AddCommand(runCmd(version), versionCmd(version))
return root return root
} }
func versionCmd() *cobra.Command { func versionCmd(version string) *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "version", Use: "version",
Short: "print the version and exit", Short: "print the version and exit",
@@ -1,4 +1,4 @@
package main package cli
import ( import (
"context" "context"
@@ -14,13 +14,13 @@ import (
"sneak.berlin/go/simplexcalc/internal/logger" "sneak.berlin/go/simplexcalc/internal/logger"
) )
func runCmd() *cobra.Command { func runCmd(version string) *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "run", Use: "run",
Short: "run the bot", Short: "run the bot",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { 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, // 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 // or until it fails. A failure is the process's exit status, so a
// supervisor restarts it. // supervisor restarts it.
func run(ctx context.Context) error { func run(ctx context.Context, version string) error {
cfg, err := config.New() cfg, err := config.New()
if err != nil { if err != nil {
return fmt.Errorf("reading configuration: %w", err) 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) ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
err = bot.Run(ctx, log, cfg.DataDir) err = bot.Run(ctx, log, cfg, bot.ChatPort)
if err != nil { if err != nil {
log.Error("stopped", "error", err) log.Error("stopped", "error", err)
+75 -4
View File
@@ -16,8 +16,10 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -36,10 +38,21 @@ import (
const ( const (
EnvDataDir = "DATA_DIR" EnvDataDir = "DATA_DIR"
EnvDebug = "DEBUG" EnvDebug = "DEBUG"
EnvPort = "PORT"
EnvAPITokenFile = "API_TOKEN_FILE" //nolint:gosec // G101: a name, not a credential
) )
// DefaultDataDir applies when DATA_DIR is absent. // Defaults, for the variables that are absent.
const DefaultDataDir = "./data" 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, // ErrInvalidConfig is the sentinel every configuration failure wraps,
// so callers can distinguish "the operator got it wrong" from "the // 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 // later, so there is exactly one moment at which configuration can be
// wrong, and it is before anything starts. // wrong, and it is before anything starts.
type Config struct { type Config struct {
// DataDir holds the SimpleX Chat database: the bot's profile, its // DataDir holds the SimpleX Chat database, with the bot's profile,
// address and its contacts. Losing it loses the address. // its address and its contacts, and the API's webhooks. Losing it
// loses the address.
DataDir string DataDir string
Debug bool 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 // loader parses one environment into a Config, accumulating every
@@ -109,6 +131,53 @@ func (l *loader) boolean(key string, def bool) bool {
return b 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 // New parses and validates the environment. An error here aborts
// startup before the chat client is launched, so there is no partially // startup before the chat client is launched, so there is no partially
// configured running state to reason about. // configured running state to reason about.
@@ -127,6 +196,8 @@ func load(v *viper.Viper) (*Config, error) {
c := &Config{ c := &Config{
DataDir: l.str(EnvDataDir, DefaultDataDir), DataDir: l.str(EnvDataDir, DefaultDataDir),
Debug: l.boolean(EnvDebug, false), Debug: l.boolean(EnvDebug, false),
Port: l.port(EnvPort, DefaultPort),
APIToken: l.tokenFile(EnvAPITokenFile),
} }
if len(l.errs) > 0 { if len(l.errs) > 0 {
+91 -1
View File
@@ -2,6 +2,9 @@ package config_test
import ( import (
"errors" "errors"
"os"
"path/filepath"
"strings"
"testing" "testing"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -27,7 +30,10 @@ func TestAbsentValuesTakeDefaults(t *testing.T) {
for name, kv := range map[string]map[string]string{ for name, kv := range map[string]map[string]string{
"unset": nil, "unset": nil,
"whitespace only": {config.EnvDataDir: " ", config.EnvDebug: " "}, "whitespace only": {
config.EnvDataDir: " ", config.EnvDebug: " ",
config.EnvPort: " ", config.EnvAPITokenFile: "\t",
},
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -44,6 +50,14 @@ func TestAbsentValuesTakeDefaults(t *testing.T) {
if c.Debug { if c.Debug {
t.Error("Debug must default off") 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") 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)
}
}
+108 -3
View File
@@ -14,6 +14,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -21,15 +22,33 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
// maxMessageSize bounds one message from the chat client. The largest // maxMessageSize bounds one message from the chat client; a larger one
// thing it sends is a record carrying a contact's profile picture, well // ends the connection. The largest it sends are the pages of chat items
// under this. // ChatItems asks for, which chatItemsPage keeps under this.
const maxMessageSize = 16 << 20 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 ( var (
// ErrClosed is returned by Command once the connection has ended. // ErrClosed is returned by Command once the connection has ended.
ErrClosed = errors.New("connection to the chat client closed") 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") errUnexpected = errors.New("unexpected response")
errCommand = errors.New("command failed") errCommand = errors.New("command failed")
) )
@@ -168,6 +187,55 @@ func (c *Client) SetAddressSettings(
return c.command(ctx, cmd, TypeUserContactLinkUpdated, nil) 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 // 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 // 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. // 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) 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 // CommandError is a command the chat client refused. Type and Detail
// are the discriminators of its chatError record, such as "errorStore" // are the discriminators of its chatError record, such as "errorStore"
// and "userContactLinkNotFound". // 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 { func commandError(ev Event) error {
var r cmdError var r cmdError
@@ -347,8 +443,17 @@ func commandError(ev Event) error {
} }
} }
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 return e
} }
}
// cmdName is a command without its arguments, for error messages: the // cmdName is a command without its arguments, for error messages: the
// arguments of /_send are a message someone wrote. // arguments of /_send are a message someone wrote.
+55 -4
View File
@@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -43,12 +44,25 @@ const (
contactConnected = `{"type":"contactConnected","user":{"userId":1}, contactConnected = `{"type":"contactConnected","user":{"userId":1},
"contact":{"contactId":3,"localDisplayName":"alice"}}` "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 // 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 // with the record in replies under the whole command or else under its
// silent for a command it has no record for, and reports every command // first word, stays silent for a command it has no record for, and
// it receives on got. // reports every command it receives on got.
type fakeChat struct { type fakeChat struct {
replies map[string]string replies map[string]string
got chan string got chan string
@@ -98,8 +112,13 @@ func (f *fakeChat) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.got <- cmd.Cmd f.got <- cmd.Cmd
resp, ok := f.replies[cmd.Cmd]
if !ok {
name, _, _ := strings.Cut(cmd.Cmd, " ") name, _, _ := strings.Cut(cmd.Cmd, " ")
if resp, ok := f.replies[name]; ok { resp, ok = f.replies[name]
}
if ok {
f.send(cmd.CorrID, resp) 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 // TestRefusedCommand: a command the chat client refuses is an error
// that names the reason. // that names the reason.
func TestRefusedCommand(t *testing.T) { func TestRefusedCommand(t *testing.T) {
+214
View File
@@ -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)
}
})
}
}
+52 -6
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"time"
) )
// Response and event types this package and the bot act on. The chat // Response and event types this package and the bot act on. The chat
@@ -14,6 +15,8 @@ const (
TypeUserContactLink = "userContactLink" TypeUserContactLink = "userContactLink"
TypeUserContactLinkCreated = "userContactLinkCreated" TypeUserContactLinkCreated = "userContactLinkCreated"
TypeUserContactLinkUpdated = "userContactLinkUpdated" TypeUserContactLinkUpdated = "userContactLinkUpdated"
TypeContactsList = "contactsList"
TypeAPIChat = "apiChat"
TypeNewChatItems = "newChatItems" TypeNewChatItems = "newChatItems"
TypeContactConnected = "contactConnected" TypeContactConnected = "contactConnected"
TypeChatCmdError = "chatCmdError" TypeChatCmdError = "chatCmdError"
@@ -47,9 +50,13 @@ type (
// User is the chat client's local user profile: the bot itself. // User is the chat client's local user profile: the bot itself.
User struct { User struct {
UserID int64 `json:"userId"` 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 { Profile struct {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
} `json:"profile"`
} }
// ConnLink is a SimpleX link. The short form is what people share; // ConnLink is a SimpleX link. The short form is what people share;
@@ -62,10 +69,13 @@ type (
// Contact is a person connected to the bot. // Contact is a person connected to the bot.
Contact struct { 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 // NewChatItems is the record of a newChatItems event, messages
// received, or sent from this profile elsewhere. // received or sent from this profile elsewhere, and of the answer
// to sending a message.
NewChatItems struct { NewChatItems struct {
ChatItems []AChatItem `json:"chatItems"` ChatItems []AChatItem `json:"chatItems"`
} }
@@ -75,25 +85,36 @@ type (
Contact Contact `json:"contact"` 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 { AChatItem struct {
ChatInfo struct { ChatInfo struct {
Type string `json:"type"` Type string `json:"type"`
Contact *Contact `json:"contact,omitempty"` Contact *Contact `json:"contact,omitempty"`
} `json:"chatInfo"` } `json:"chatInfo"`
ChatItem ChatItem `json:"chatItem"`
}
// 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 { ChatItem struct {
ChatDir tagged `json:"chatDir"` ChatDir tagged `json:"chatDir"`
Meta struct { Meta struct {
ItemID int64 `json:"itemId"` 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"` } `json:"meta"`
Content struct { Content struct {
Type string `json:"type"` Type string `json:"type"`
MsgContent *MsgContent `json:"msgContent,omitempty"` MsgContent *MsgContent `json:"msgContent,omitempty"`
} `json:"content"` } `json:"content"`
} `json:"chatItem"`
} }
// MsgContent is a message body. Only "text" is sent or read here. // 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 { MsgContent struct {
Type string `json:"type"` Type string `json:"type"`
Text string `json:"text"` 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. // Message is a text message a contact sent to the bot.
type Message struct { type Message struct {
ContactID int64 ContactID int64
@@ -190,6 +218,24 @@ func cmdSetAddressSettings(userID int64, s AddressSettings) (string, error) {
return "/_address_settings " + strconv.FormatInt(userID, 10) + " " + string(b), nil 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) { func cmdSendText(contactID, quotedItemID int64, text string) (string, error) {
b, err := json.Marshal([]composedMessage{{ b, err := json.Marshal([]composedMessage{{
QuotedItemID: quotedItemID, QuotedItemID: quotedItemID,