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

Open
clawbot wants to merge 7 commits from next into main
31 changed files with 4175 additions and 213 deletions
+5 -3
View File
@@ -1,6 +1,6 @@
---
title: Agent Guidance
last_modified: 2026-09-26
last_modified: 2026-09-29
---
This file is the single source of guidance for any automated agent
@@ -118,9 +118,11 @@ Do not weaken them.
## Layout
```
cmd/simplexcalc/ cobra command tree; main(), run and version
cmd/simplexcalc/ main(), a single call into internal/cli
internal/api/ the HTTP API: its credential, headers and endpoints
internal/bot/ startup, address setup, and the reply to a message
internal/calc/ the arithmetic: go/parser and go/constant
internal/calc/ the arithmetic: its own parser, and go/constant
internal/cli/ cobra command tree: run and version
internal/config/ viper-backed configuration; the abort-on-garbage rule
internal/logger/ log/slog, JSON always
internal/simplex/ the simplex-chat child process and its WebSocket API
+7 -3
View File
@@ -109,9 +109,9 @@ WORKDIR /app
COPY --from=builder /build/bin/simplexcalc /app/simplexcalc
# Data directory: the SimpleX database, which holds the bot's profile,
# its keys, its address and its contacts. Mount a volume over it;
# without one, the bot gets a new address every time the container is
# recreated.
# its keys, its address and its contacts, and the API's webhooks. Mount
# a volume over it; without one, the bot gets a new address and loses
# its webhooks every time the container is recreated.
RUN mkdir -p /var/lib/simplexcalc && \
chown simplexcalc:simplexcalc /var/lib/simplexcalc
@@ -119,4 +119,8 @@ USER simplexcalc
ENV DATA_DIR=/var/lib/simplexcalc
# The API, on its default PORT. The chat client's WebSocket on 5225 is
# not exposed: it has no authentication.
EXPOSE 8080
CMD ["/app/simplexcalc", "run"]
+304 -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.
Send it `2 + 2` and it replies `4`; send `5 * 5/2` and it replies
`12.5`. It understands decimal numbers, `+ - * /`, unary minus and
parentheses, and computes exactly, so `0.1 + 0.2` is `0.3`. Anything
else gets a short explanation instead of a result.
`12.5`. It understands decimal numbers, `+ - * /`, powers written `2^10`
or `2**10`, remainders written `7 % 3`, signs and parentheses, and
computes exactly, so `0.1 + 0.2` is `0.3`. Anything else gets a short
explanation instead of a result.
## Getting Started
Build the image and run the bot, with its SimpleX profile on a named
volume:
Build the image, write a random API credential onto a named volume, and
run the bot with its SimpleX profile on the same volume:
```sh
git clone git@git.eeqj.de:clawbot/simplexcalc.git
git clone git@git.eeqj.de:sneak/simplexcalc.git
cd simplexcalc
make docker
docker run --rm -v simplexcalc-data:/var/lib/simplexcalc simplexcalc \
sh -c 'umask 077 && od -An -N32 -tx1 /dev/urandom | tr -d " \n" \
>/var/lib/simplexcalc/api-token'
docker run -d --name simplexcalc --restart unless-stopped \
-v simplexcalc-data:/var/lib/simplexcalc simplexcalc
-v simplexcalc-data:/var/lib/simplexcalc \
-p 127.0.0.1:8080:8080 -e API_TOKEN_FILE=/var/lib/simplexcalc/api-token \
simplexcalc
docker logs simplexcalc 2>&1 | grep '"msg":"ready"'
```
The credential is 32 random bytes written as 64 hexadecimal characters
to `/var/lib/simplexcalc/api-token`, readable only by the bot's user.
`-p 127.0.0.1:8080:8080` makes the API reachable from this host only;
see [API](#api).
The `ready` log line carries the bot's contact address: `address` is the
short link to share, and `full_address` is the same address in the long
form that older SimpleX clients need. Open the link in any SimpleX Chat
@@ -62,11 +73,235 @@ development.
is never quietly replaced by the default. Defaults apply only to
variables that are absent.
- `DATA_DIR` — where the SimpleX database lives: the bot's profile, its
keys, its address and its contacts. Default `./data`; the image sets
- `DATA_DIR` — where the bot keeps what it must not lose: the SimpleX
database, which holds the bot's profile, its keys, its address and its
contacts, and `webhooks.json`, which holds the webhooks registered
through the [API](#api). Default `./data`; the image sets
`/var/lib/simplexcalc`.
- `DEBUG` — `true` or `false`, default `false`. `true` logs every event
the chat client sends.
- `PORT` — the TCP port the API listens on, on all interfaces: a whole
number from 1 to 65535, default `8080`.
- `API_TOKEN_FILE` — path of a file holding the API credential, at least
32 characters not counting the whitespace around them. The file is
read once, at startup; one that cannot be read, or holds a shorter
credential, aborts startup. Absent, the API still listens but refuses
every request, and startup logs a warning saying so.
## API
An HTTP API beside the chat client lets another program read the bot's
chats, send messages in them and register webhooks on them. It speaks
JSON on `PORT`.
**Authentication.** Every request carries the credential from
`API_TOKEN_FILE`:
```
Authorization: Bearer {credential}
```
A request without it, or with a wrong one, gets `401` with
`WWW-Authenticate: Bearer` and `{"error":"unauthorized"}`. No path is
exempt. Without `API_TOKEN_FILE`, every request gets that answer. The
file is read at startup, so a new credential takes a restart.
**Errors** are JSON with a short explanation, such as
`{"error":"not found"}`; what went wrong inside goes to the bot's log.
### `GET /api/v1/chats`
The bot's chats, ordered by `id`. The bot talks to people only one to
one, so each chat is one of its contacts.
```sh
TOKEN=$(docker exec simplexcalc cat /var/lib/simplexcalc/api-token)
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/api/v1/chats
```
```json
{
"chats": [
{ "id": 3, "display_name": "tester", "contact_deleted": false },
{ "id": 4, "display_name": "tester", "contact_deleted": true }
]
}
```
- `id` — the chat's number, which is its contact's number in the chat
client.
- `display_name` — the name the contact gave themselves. Nothing makes
it unique.
- `contact_deleted` — `true` once the contact has deleted their chat
with the bot. The chat stays in the list, but nothing more reaches
them.
### `GET /api/v1/chats/{id}/messages`
The latest messages in the chat `id`, oldest first. `count`, a whole
number from 1 to 100 and 20 if absent, is how many of the chat's latest
items to read. The chat client also records events in a chat, such as
the contact connecting, and only messages are returned, so fewer than
`count` can come back.
```sh
curl -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:8080/api/v1/chats/3/messages?count=5'
```
```json
{
"messages": [
{
"id": 9,
"direction": "received",
"type": "text",
"text": "2 + 2",
"time": "2026-09-29T03:14:34Z"
},
{
"id": 10,
"direction": "sent",
"type": "text",
"text": "4",
"time": "2026-09-29T03:14:35.101223457Z"
}
]
}
```
A message has:
- `id` — its number, unique across all the bot's chats.
- `direction` — `received` from the contact, or `sent` by the bot.
- `type` — `text`, or another kind of SimpleX message, such as `image`,
`file` or `voice`.
- `text` — the text; for a message that is not `text`, its caption,
which can be empty.
- `time` — in RFC 3339, in UTC: for a received message, when it reached
the SimpleX relay, to the second; for a sent one, when the bot sent
it.
The answer is `400` if `count` is not a whole number from 1 to 100, and
`404` if `GET /api/v1/chats` does not list `id`. A query the server
cannot read gets `400` with `the query cannot be read`; examples are a
query that holds a `;`, a `%` not followed by two hexadecimal digits, or
more than 10,000 parts separated by `&`.
### `POST /api/v1/chats/{id}/messages`
Sends the `text` in the body to the chat `id`, and answers `201` with
the message as sent. The answer comes once the chat client has taken the
message, before it reaches the contact.
```sh
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"hello"}' http://127.0.0.1:8080/api/v1/chats/3/messages
```
```json
{
"message": {
"id": 12,
"direction": "sent",
"type": "text",
"text": "hello",
"time": "2026-09-29T03:14:43.519552587Z"
}
}
```
Nothing is sent, and the answer is:
- `400` if the body is not JSON of that shape, or `text` is empty;
- `404` if `GET /api/v1/chats` does not list `id`;
- `409` if the contact cannot receive messages: they have deleted their
chat with the bot (`contact_deleted` is `true`), or have not finished
connecting;
- `413` if the body is over 64 KiB, or the text is too long for one
SimpleX message, which holds about 15,000 bytes.
### `POST /api/v1/chats/{id}/webhooks`
Registers the `url` in the body as a webhook on the chat `id`, and
answers `201` with the webhook. If the chat already has a webhook with
the same `url`, character for character, the answer is `200` with that
webhook instead.
```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. The bot does not post anything
to a webhook yet.
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.
## Entrypoints
@@ -142,8 +377,8 @@ container.
- **One process tree, one container.** `simplexcalc run` starts the
SimpleX Chat command-line client, `simplex-chat`, as a child process,
with its database under `$DATA_DIR/simplex` and its WebSocket API on
`127.0.0.1:5225`. The API has no authentication, which is why it is
never exposed outside the container.
`127.0.0.1:5225`. That WebSocket has no authentication, which is why
it is never exposed outside the container.
- **The protocol** (`internal/simplex`) is JSON over that WebSocket: a
command carries a correlation id, its response carries the same id,
and anything without one is an event. Only the fields the bot reads
@@ -154,19 +389,60 @@ container.
accept every contact request and to greet each new contact. The first
start creates the profile itself, a bot profile named `calc`. The
address is logged in the `ready` line.
- **The API** (`internal/api`): an HTTP server, routed with chi, that
starts once set-up is done; if it cannot listen, the bot exits with an
error, as when the chat client fails. Every request must carry the
credential, compared in constant time; every response carries headers
that forbid framing, content sniffing, caching and referrers, and a
`Permissions-Policy` that denies the camera, microphone and location;
a request body is capped at 64 KiB and a request's work at 10 seconds.
Handlers call the chat client on the request's own goroutine, never on
the one that delivers events, which also delivers the chat client's
answers. When the bot stops, requests in progress get 5 seconds to
finish before the chat client is stopped.
- **Webhooks** (`internal/api`) are read from `$DATA_DIR/webhooks.json`
before the chat client starts. Changes are made one at a time, and
each rewrites the file whole: the bot writes a temporary file beside
it, named `webhooks.json.` and digits, and renames it over the old
one. A crash leaves the old file or the new one, never part of one,
and at worst a stray temporary file, which the bot ignores.
- **Replies**: for each text message a contact sends in a direct chat,
the bot sends back the result, as a reply quoting the message. Group
messages, files and the bot's own messages are ignored.
- **Arithmetic** (`internal/calc`): the text is parsed as a Go
expression with `go/parser`, and only numbers, `+ - * /`, unary signs
and parentheses are evaluated; anything else in the syntax tree is
refused. `go/constant` computes with exact rationals. Numbers are read
as decimal, so `010` is ten. Input over 256 bytes is refused, so a
- **Arithmetic** (`internal/calc`): a small parser of its own reads
numbers, `+ - * / % ^`, signs and parentheses, and refuses anything
else. `go/constant` computes with exact rationals. `^`, also written
`**`, is a power: it binds tighter than `*`, `/`, `%` and a sign on
its left, and groups to the right, so `2^3^2` is `512`, `-2^2` is
`-4`, `(-2)^2` is `4` and `2^-1` is `0.5`. `%` is the remainder and
ranks with `*` and `/`; its result takes the sign of the divisor, as
in Python, so `7 % 3` is `1`, `-7 % 3` is `2` and `7.5 % 2` is `1.5`.
A power with a whole exponent is exact, so `0.1^2` is `0.01` and
`2^-1400 * 2^1400` is `1`, unless `go/constant` could hold the result
only rounded; that power, and one with a fractional exponent, is
computed as a double, so `2^0.5` is `1.4142135623730951`. A negative
number to a fractional power is refused, as having no real result.
Numbers are read as decimal, so `010` is ten. Input over 256 bytes is
refused, exact powers are capped, and every number is held as a
fraction, whole numbers too, under the 4096-bit limit below, so a
message cannot make the bot do unbounded work. Whole numbers below
10<sup>21</sup> are written exactly; other results in the shortest
form that reads back as the same double, in exponent notation from
10<sup>21</sup> up and below 10<sup>-6</sup>. A result beyond the
range of a double is refused as too large.
10<sup>21</sup> are written exactly. Other results inside the normal
range of a double, about 2.2e-308 to 1.8e308 in magnitude, where a
double keeps all its digits, are written in the shortest form that
reads back as the same double, in exponent notation from
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
it drops, the bot exits with an error and the container's restart
policy starts both again. `SIGTERM` stops the bot, which stops the
@@ -180,11 +456,14 @@ container.
## Operating it
**Backup.** Everything durable is the SimpleX database on the volume:
`simplex_chat.db` and `simplex_agent.db`. Stop the container before
copying them, since a copy taken from under the running client can be
inconsistent. The database holds the bot's keys, so a copy lets its
holder answer as the bot; keep it as private as the running instance.
**Backup.** Everything durable is on the volume: the SimpleX database,
`simplex_chat.db` and `simplex_agent.db`; the API credential,
`api-token`; and the webhooks, `webhooks.json`. Stop the container
before copying the database, since a copy taken from under the running
client can be inconsistent. The database holds the bot's keys, so a copy
lets its holder answer as the bot; the credential lets its holder use
the API; and a webhook's URL can hold a secret of the program it points
at. Keep all three as private as the running instance.
**Upgrade.** Rebuild the image and recreate the container with the same
volume. The chat client migrates its database on start. A newer
+3 -11
View File
@@ -2,12 +2,10 @@
package main
import (
"fmt"
"os"
)
// appname is the name the program reports in its logs and its version.
const appname = "simplexcalc"
"sneak.berlin/go/simplexcalc/internal/cli"
)
// version is injected at build time with -ldflags "-X main.version=...".
// The Dockerfile passes VERSION; a `go build` without it says "dev",
@@ -16,11 +14,5 @@ const appname = "simplexcalc"
var version = "dev"
func main() {
err := rootCmd().Execute()
if err != nil {
// cobra has already printed the error; this only sets the exit
// status, which is what a supervisor and a shell script read.
fmt.Fprintln(os.Stderr, "exiting: "+err.Error())
os.Exit(1)
}
os.Exit(cli.Main(version))
}
+16
View File
@@ -27,6 +27,22 @@ with no deprecation warning.
# Completed Steps
- 2026-09-29 Exact results past the range of a double, such as `2^1200`,
are written to 17 significant digits instead of being refused, and a
fractional power of such a number, such as `(2^1200)^0.5`, is answered
- 2026-09-29 `POST`, `GET` and `DELETE` on a chat's webhooks, under
`/api/v1/chats/{id}/webhooks`, kept in `$DATA_DIR/webhooks.json`
- 2026-09-29 `GET` and `POST /api/v1/chats/{id}/messages`: a chat's
latest messages, and sending one
- 2026-09-29 Added the HTTP API in `internal/api`: the server on `PORT`,
the bearer credential from `API_TOKEN_FILE`, security headers and
limits, and `GET /api/v1/chats`
- 2026-09-28 Powers (`^`, also written `**`) and remainders (`%`) in
`internal/calc`, which now reads expressions with a parser of its own
in place of `go/parser`
- 2026-09-28 Moved the command tree and the `run` and `version` commands
from `cmd/simplexcalc/` into `internal/cli`; `cmd/simplexcalc/main.go`
is now a single call to `cli.Main`
- 2026-09-26 Seeded from `go-template-repo` (renamed, its HTTP service
removed) and built the bot: `internal/calc` for the arithmetic,
`internal/simplex` for the chat client process and its WebSocket API,
+1
View File
@@ -3,6 +3,7 @@ module sneak.berlin/go/simplexcalc
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.3.2
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.10.2
+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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+240
View File
@@ -0,0 +1,240 @@
// 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.
//
// 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).
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)
}
}
+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)
}
}
+92 -28
View File
@@ -8,12 +8,15 @@ import (
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"sneak.berlin/go/simplexcalc/internal/api"
"sneak.berlin/go/simplexcalc/internal/calc"
"sneak.berlin/go/simplexcalc/internal/config"
"sneak.berlin/go/simplexcalc/internal/simplex"
)
@@ -22,14 +25,14 @@ import (
const DisplayName = "calc"
// Welcome is sent to everyone whose contact request the bot accepts.
const Welcome = "Send me arithmetic, such as 2 + 2 or 5 * 5/2, " +
const Welcome = "Send me arithmetic, such as 2 + 2, 5 * 5/2, 2^10 or 7 % 3, " +
"and I will reply with the result."
const (
// chatPort is where the chat client serves its API, on localhost
// inside the bot's own container.
chatPort = 5225
// ChatPort is where the chat client serves its API, on localhost inside
// the bot's own container.
const ChatPort = 5225
const (
// connectTimeout bounds the wait for a freshly started chat client
// to open its API, which includes creating or migrating the
// database.
@@ -42,26 +45,43 @@ const (
// retryInterval paces the connection attempts.
retryInterval = 250 * time.Millisecond
// apiStopTimeout is how long API requests in progress get to finish
// when the bot stops, before their connections are closed.
apiStopTimeout = 5 * time.Second
dataDirMode = 0o700
)
var errExited = errors.New("simplex-chat exited")
// Run starts the chat client with its database in dataDir, connects to
// it, sets up the bot's address, and answers messages until ctx is
// cancelled — which is a clean stop and returns nil — or until the chat
// client or the connection to it fails, which returns the error.
func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
err := os.MkdirAll(dataDir, dataDirMode)
// Run reads the webhooks kept in cfg.DataDir, starts the chat client with
// its database there, serving its API on localhost at chatPort, connects
// to it, sets up the bot's address, then answers messages and serves the
// bot's API until ctx is cancelled — which is a clean stop and returns
// nil — or until the chat client, the connection to it or the API's
// listener fails, which returns the error.
func Run(
ctx context.Context, log *slog.Logger, cfg *config.Config, chatPort int,
) error {
err := os.MkdirAll(cfg.DataDir, dataDirMode)
if err != nil {
return fmt.Errorf("creating data directory: %w", err)
}
// Before the chat client starts, so that a file that cannot be read
// stops the bot as configuration that cannot be read does.
webhooks, err := api.ReadWebhooks(cfg.DataDir)
if err != nil {
return err
}
// Cancelling this stops the chat client; the deferred wait makes
// Run return only once it has exited, whatever path Run takes.
cliCtx, stopCLI := context.WithCancel(ctx)
// Cancelling ctx does not reach it, so that the API, stopped first,
// can finish its requests while the chat client still answers.
cliCtx, stopCLI := context.WithCancel(context.WithoutCancel(ctx))
cli, err := simplex.StartCLI(cliCtx, log, filepath.Join(dataDir, "simplex"),
cli, err := simplex.StartCLI(cliCtx, log, filepath.Join(cfg.DataDir, "simplex"),
DisplayName, chatPort)
if err != nil {
stopCLI()
@@ -74,18 +94,39 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
<-cli.Done()
}()
client, err := connect(ctx, log, cli)
client, err := connect(ctx, log, cli, chatPort)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
err = setUp(ctx, log, client)
user, err := setUp(ctx, log, client)
if err != nil {
return err
}
srv := api.New(api.Params{
Log: log,
Client: client,
UserID: user.UserID,
Port: cfg.Port,
Token: cfg.APIToken,
Webhooks: webhooks,
})
served := make(chan error, 1)
go func() {
served <- srv.ListenAndServe()
}()
// Deferred last, so it runs first: requests in progress finish while
// the chat client is still there to answer them.
defer stopAPI(ctx, log, srv)
log.Info("starting the API", "port", cfg.Port)
select {
case <-ctx.Done():
return nil
@@ -93,12 +134,30 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
return fmt.Errorf("%w: %w", simplex.ErrClosed, client.Err())
case <-cli.Done():
return fmt.Errorf("%w: %w", errExited, cli.Err())
case err := <-served:
return fmt.Errorf("serving the API: %w", err)
}
}
// connect waits for the chat client to open its API and connects to it.
// stopAPI stops the API server, giving requests in progress up to
// apiStopTimeout to finish before closing their connections.
func stopAPI(ctx context.Context, log *slog.Logger, srv *http.Server) {
// ctx is usually cancelled by now: that is why the bot is stopping.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), apiStopTimeout)
defer cancel()
err := srv.Shutdown(ctx)
if err != nil {
log.Warn("stopping the API", "error", err)
_ = srv.Close()
}
}
// connect waits for the chat client to open its API on chatPort and
// connects to it.
func connect(
ctx context.Context, log *slog.Logger, cli *simplex.CLI,
ctx context.Context, log *slog.Logger, cli *simplex.CLI, chatPort int,
) (*simplex.Client, error) {
ctx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
@@ -125,19 +184,22 @@ func connect(
// setUp gives the bot a long-term contact address, creating it on the
// first start, and sets it to accept every contact request and to greet
// each new contact. The settings are written on every start, so an
// address whose settings were changed by hand is put right.
func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error {
// address whose settings were changed by hand is put right. It returns
// the bot's user profile.
func setUp(
ctx context.Context, log *slog.Logger, client *simplex.Client,
) (simplex.User, error) {
ctx, cancel := context.WithTimeout(ctx, setupTimeout)
defer cancel()
user, err := client.ActiveUser(ctx)
if err != nil {
return fmt.Errorf("reading the bot's profile: %w", err)
return user, fmt.Errorf("reading the bot's profile: %w", err)
}
link, ok, err := client.Address(ctx, user.UserID)
if err != nil {
return fmt.Errorf("reading the bot's address: %w", err)
return user, fmt.Errorf("reading the bot's address: %w", err)
}
if !ok {
@@ -145,7 +207,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error
link, err = client.CreateAddress(ctx, user.UserID)
if err != nil {
return fmt.Errorf("creating the bot's address: %w", err)
return user, fmt.Errorf("creating the bot's address: %w", err)
}
}
@@ -154,7 +216,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error
AutoReply: &simplex.MsgContent{Type: "text", Text: Welcome},
})
if err != nil {
return fmt.Errorf("setting the bot's address to accept everyone: %w", err)
return user, fmt.Errorf("setting the bot's address to accept everyone: %w", err)
}
log.Info("ready",
@@ -163,7 +225,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error
"full_address", link.FullLink,
)
return nil
return user, nil
}
// handle answers each text message a contact sends.
@@ -221,10 +283,12 @@ func Reply(text string) string {
calc.MaxInputLength)
case errors.Is(err, calc.ErrDivisionByZero):
return "I cannot divide by zero."
case errors.Is(err, calc.ErrTooLarge):
return "The result is too large for me."
case errors.Is(err, calc.ErrOutOfRange):
return "That needs a number too large or too small for me."
case errors.Is(err, calc.ErrNoRealResult):
return "A negative number to a fractional power has no real result."
default:
return "I only understand arithmetic: numbers, + - * / and " +
"parentheses, such as 5 * 5/2."
return "I only understand arithmetic: numbers, + - * /, ^ for a power, " +
"% for a remainder, and parentheses, such as 5 * 5/2 or 2^10."
}
}
+8 -3
View File
@@ -16,6 +16,9 @@ func TestReply(t *testing.T) {
for in, want := range map[string]string{
"2 + 2": "4",
"5 * 5/2": "12.5",
"2^10": "1024",
"7 % 3": "1",
"2^1200": "1.7218479456385751e+361",
} {
if got := bot.Reply(in); got != want {
t.Errorf("Reply(%q) = %q, want %q", in, got, want)
@@ -23,9 +26,11 @@ func TestReply(t *testing.T) {
}
for in, want := range map[string]string{
"hello": "I only understand arithmetic",
"1 / 0": "I cannot divide by zero.",
"1e400": "The result is too large for me.",
"hello": "I only understand arithmetic",
"1 / 0": "I cannot divide by zero.",
"1e1300": "That needs a number too large or too small for me.",
"1e-1300": "That needs a number too large or too small for me.",
"(-8)^0.5": "A negative number to a fractional power has no real",
strings.Repeat("1+", calc.MaxInputLength) + "1": "That is too long for me",
} {
if got := bot.Reply(in); !strings.HasPrefix(got, want) {
+252
View File
@@ -0,0 +1,252 @@
package bot_test
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"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
)
// TestMain lets this test binary be the chat client as well: started
// under the chat client's name, as TestStopDuringRequest 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]
}
marker := filepath.Join(filepath.Dir(arg("--database")), asked)
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, marker)
}),
}
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 marker, then holds its answer for contactsDelay.
func answer(w http.ResponseWriter, r *http.Request, marker 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"], " ")
record, ok := records[name]
if !ok {
continue
}
if name == "/_contacts" {
_ = os.WriteFile(marker, nil, 0o600)
time.Sleep(contactsDelay)
}
_ = conn.WriteJSON(map[string]any{
"corrId": cmd["corrId"],
"resp": json.RawMessage(record),
})
}
}
// 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) {
// Run starts the chat client from PATH: put this test binary there
// under the chat client's name.
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)
}
t.Setenv("PATH", bin)
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t), APIToken: credential}
// 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)
}
})
// Stop the bot once the request below is waiting on the chat client.
go func() {
for ctx.Err() == nil {
_, err := os.Stat(filepath.Join(cfg.DataDir, asked))
if err == nil {
stop()
}
time.Sleep(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)
}
}
// 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)
}
}
// 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):
}
}
}
+415 -82
View File
@@ -1,29 +1,45 @@
// Package calc evaluates the arithmetic people send the bot: decimal
// numbers, + - * /, unary minus and parentheses.
// numbers, + - * / % ^, signs and parentheses.
//
// The expression is parsed by go/parser and computed by go/constant,
// which does exact rational arithmetic: 5 * 5/2 is exactly 12.5, and
// 0.1 + 0.2 is exactly 0.3, so a result carries no binary floating
// point noise until the moment it is formatted.
// The expression is read by a small parser of its own, because Go's
// grammar has no power operator (^ is XOR there), and computed by
// go/constant, which does exact rational arithmetic: 5 * 5/2 is exactly
// 12.5, and 0.1 + 0.2 is exactly 0.3, so a result carries no binary
// floating point noise until the moment it is formatted. A power is the
// exception: one with a fractional exponent, or whose result go/constant
// cannot hold exactly, is computed in float64.
package calc
import (
"errors"
"go/ast"
"go/constant"
"go/parser"
"go/token"
"math"
"math/big"
"regexp"
"strconv"
"strings"
)
// MaxInputLength caps an expression, in bytes, so a message cannot make
// the bot do unbounded work. Every operation's cost grows with the size
// of its operands, and the operands can only grow with the input.
// MaxInputLength caps an expression, in bytes. With maxExactExponent,
// which caps a power computed exactly, and bitLimit, which caps every
// number, it keeps a message from making the bot do unbounded work.
const MaxInputLength = 256
// bitLimit caps the numerator and denominator of every number: see
// exact.
const bitLimit = 4096
// maxExactExponent is the largest exponent, either way, of a power
// computed exactly. Past it, x^n has a numerator or denominator of more
// than 4096 bits, which go/constant holds only rounded, unless x is 0 or
// 1, and float64 computes those exactly.
const maxExactExponent = 4096
// smallestNormal is the smallest positive normal double, about 2.2e-308.
// Below it a double keeps fewer digits, down to one.
const smallestNormal = 0x1p-1022
// Results of magnitude plainUpper or more are written in exponent form
// (1e+21 rather than twenty-two digits), and so are fractions smaller
// than plainLower (1e-07 rather than 0.0000001).
@@ -32,150 +48,467 @@ const (
plainLower = 1e-6
)
// A result past the normal range of a double is written to
// significantDigits significant digits, the most the shortest form of a
// double takes. It is rounded to them from a float of floatPrecision
// bits, the bits a numerator or denominator can hold and 64 more for the
// digits, so that the float rounds as the exact result would. The square
// roots of a power's base past that range are taken in such a float too:
// see nonNegativePower.
const (
significantDigits = 17
floatPrecision = bitLimit + 64
)
// The precedence of the binary operators: the higher, the tighter the
// operator binds.
const (
sumPrecedence = iota + 1
productPrecedence
powerPrecedence
)
// Errors returned by Evaluate. The bot turns each into a reply.
var (
ErrTooLong = errors.New("expression too long")
ErrNotArithmetic = errors.New("not an arithmetic expression")
ErrDivisionByZero = errors.New("division by zero")
ErrTooLarge = errors.New("result too large")
ErrOutOfRange = errors.New("number too large or too small")
ErrNoRealResult = errors.New("no real result")
)
// decimalLiteral is the only number syntax accepted. Go's own literal
// syntax is wider, and parts of it are traps for someone typing
// arithmetic: 010 is octal 8, and 0x10, 1_000 and 1i are not what a
// calculator user means by a number.
var decimalLiteral = regexp.MustCompile(
`^([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$`,
// decimal is the only number syntax accepted. Go's own literal syntax is
// wider, and parts of it are traps for someone typing arithmetic: 010 is
// octal 8, and 0x10, 1_000 and 1i are not what a calculator user means
// by a number. Here the x, _ or i matches no token and is refused.
const decimal = `([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?`
var (
// nextToken matches the token at the start of the input, after any
// whitespace: an operator, a parenthesis or a number. ** comes
// before * so that it is read as one token.
nextToken = regexp.MustCompile(`^\s*(\*\*|[-+*/%^()]|` + decimal + `)`)
// decimalLiteral matches a token that is a number.
decimalLiteral = regexp.MustCompile(`^` + decimal + `$`)
)
// Evaluate computes an arithmetic expression and returns its result as
// text: whole numbers without a decimal point, fractions in the
// shortest form that reads back as the same float64.
// text, written as format describes.
func Evaluate(input string) (string, error) {
s := strings.TrimSpace(input)
if len(s) > MaxInputLength {
return "", ErrTooLong
}
if s == "" {
return "", ErrNotArithmetic
}
expr, err := parser.ParseExpr(s)
if err != nil {
return "", ErrNotArithmetic
}
v, err := eval(expr)
tokens, err := tokenize(s)
if err != nil {
return "", err
}
return format(v)
p := parser{tokens: tokens}
v, err := p.expr(sumPrecedence)
if err != nil {
return "", err
}
if p.next() != "" {
return "", ErrNotArithmetic
}
return format(v), nil
}
// eval walks the syntax tree, allowing only the node types and
// operators of arithmetic. Anything else — identifiers, calls, strings,
// shifts, comparisons — is refused, not evaluated.
func eval(e ast.Expr) (constant.Value, error) {
switch n := e.(type) {
case *ast.BasicLit:
return literal(n)
case *ast.ParenExpr:
return eval(n.X)
case *ast.UnaryExpr:
if n.Op != token.ADD && n.Op != token.SUB {
// tokenize splits an expression into operators, parentheses and
// numbers, and refuses anything else. ** is returned as ^.
func tokenize(s string) ([]string, error) {
var tokens []string
for strings.TrimSpace(s) != "" {
m := nextToken.FindStringSubmatch(s)
if m == nil {
return nil, ErrNotArithmetic
}
x, err := eval(n.X)
tok := m[1]
if tok == "**" {
tok = "^"
}
tokens = append(tokens, tok)
s = s[len(m[0]):]
}
return tokens, nil
}
// parser computes an expression as it reads it, by precedence climbing:
// expr reads operands joined by operators of at least a given
// precedence, and hands the right operand of each to a deeper call that
// takes only the operators that bind tighter, so those are applied
// first.
type parser struct {
tokens []string
}
// next removes and returns the next token, or "" at the end.
func (p *parser) next() string {
tok := p.peek()
if tok != "" {
p.tokens = p.tokens[1:]
}
return tok
}
// peek returns the next token, or "" at the end, and leaves it unread.
func (p *parser) peek() string {
if len(p.tokens) == 0 {
return ""
}
return p.tokens[0]
}
// expr reads and computes an expression whose binary operators all have
// at least minPrecedence. Operators of equal precedence group to the
// left, 8/2/2 is (8/2)/2, except ^, which groups to the right: 2^3^2 is
// 2^(3^2).
func (p *parser) expr(minPrecedence int) (constant.Value, error) {
x, err := p.operand()
if err != nil {
return nil, err
}
for {
op := p.peek()
prec := precedence(op)
if prec < minPrecedence {
return x, nil
}
p.next()
rightPrecedence := prec + 1
if op == "^" {
rightPrecedence = prec
}
y, err := p.expr(rightPrecedence)
if err != nil {
return nil, err
}
return constant.UnaryOp(n.Op, x, 0), nil
case *ast.BinaryExpr:
return binary(n)
default:
return nil, ErrNotArithmetic
x, err = apply(x, op, y)
if err != nil {
return nil, err
}
}
}
func binary(n *ast.BinaryExpr) (constant.Value, error) {
switch n.Op { //nolint:exhaustive // every other operator is refused.
case token.ADD, token.SUB, token.MUL, token.QUO:
// operand reads a number, an expression in parentheses, or a sign and
// its operand. A sign binds more loosely than a power that follows it,
// so -2^2 is -(2^2), and 2^-1 is 2^(-1).
func (p *parser) operand() (constant.Value, error) {
switch tok := p.next(); tok {
case "+", "-":
x, err := p.expr(powerPrecedence)
if err != nil {
return nil, err
}
if tok == "-" {
x = constant.UnaryOp(token.SUB, x, 0)
}
return x, nil
case "(":
x, err := p.expr(sumPrecedence)
if err != nil {
return nil, err
}
if p.next() != ")" {
return nil, ErrNotArithmetic
}
return x, nil
default:
return number(tok)
}
}
// precedence returns the precedence of a binary operator, and 0 for any
// other token, which ends an expression.
func precedence(op string) int {
switch op {
case "+", "-":
return sumPrecedence
case "*", "/", "%":
return productPrecedence
case "^":
return powerPrecedence
default:
return 0
}
}
func number(tok string) (constant.Value, error) {
if !decimalLiteral.MatchString(tok) {
return nil, ErrNotArithmetic
}
x, err := eval(n.X)
// Read as FLOAT, which makes every literal decimal and a fraction
// (see exact): as INT, a leading zero would make it octal.
v := constant.MakeFromLiteral(tok, token.FLOAT, 0)
// A literal such as 1e1300 or 1e-1233 is past bitLimit: see exact.
if !exact(v) {
return nil, ErrOutOfRange
}
// One too small even to be held rounded, such as 1e-999999999, is
// read as 0.
mantissa, _, _ := strings.Cut(strings.ToLower(tok), "e")
if constant.Sign(v) == 0 && strings.ContainsAny(mantissa, "123456789") {
return nil, ErrOutOfRange
}
return v, nil
}
// apply computes x op y.
func apply(x constant.Value, op string, y constant.Value) (constant.Value, error) {
var (
v constant.Value
err error
)
switch op {
case "+":
v = constant.BinaryOp(x, token.ADD, y)
case "-":
v = constant.BinaryOp(x, token.SUB, y)
case "*":
v = constant.BinaryOp(x, token.MUL, y)
case "/":
v, err = divide(x, y)
case "%":
v, err = modulo(x, y)
case "^":
v, err = power(x, y)
default:
err = ErrNotArithmetic
}
if err != nil {
return nil, err
}
y, err := eval(n.Y)
if err != nil {
return nil, err
if !exact(v) {
return nil, ErrOutOfRange
}
return v, nil
}
func divide(x, y constant.Value) (constant.Value, error) {
// constant.BinaryOp panics on a zero divisor.
if n.Op == token.QUO && constant.Sign(y) == 0 {
if constant.Sign(y) == 0 {
return nil, ErrDivisionByZero
}
// token.QUO divides exactly, integers included: 25/2 is 12.5.
v := constant.BinaryOp(x, n.Op, y)
return constant.BinaryOp(x, token.QUO, y), nil
}
// go/constant represents an overflow to infinity as Unknown.
if v.Kind() == constant.Unknown {
return nil, ErrTooLarge
// modulo computes x % y, whose result takes the sign of y, as in Python:
// -7 % 3 is 2 and 7 % -3 is -2. It is exact for decimals too: 7.5 % 2
// is 1.5.
func modulo(x, y constant.Value) (constant.Value, error) {
q, err := divide(x, y)
if err != nil {
return nil, err
}
// The fractional part of a rounded quotient, and so the remainder,
// would be wrong.
if !exact(q) {
return nil, ErrOutOfRange
}
// x % y is y times the fractional part of x/y, which is at least 0
// and less than 1, so the result has the sign of y. It is not
// computed as x minus y times the whole part of x/y: that product
// can be too large to hold exactly when the remainder is not.
//
// For x/y = n/d the fractional part is (n mod d)/d, exact because d
// is. token.REM truncates, leaving the sign of n; adding d brings a
// negative one into range.
n, d := constant.Num(q), constant.Denom(q)
m := constant.BinaryOp(n, token.REM, d)
if constant.Sign(m) < 0 {
m = constant.BinaryOp(m, token.ADD, d)
}
return constant.BinaryOp(y, token.MUL, constant.BinaryOp(m, token.QUO, d)), nil
}
// power computes x^y. A negative x needs a whole y, and its sign is
// applied here: math.Pow would take it from the parity of y's float64
// value, and every float64 from 2^53 up is even.
func power(x, y constant.Value) (constant.Value, error) {
// n is y if y is a whole number, and Unknown otherwise.
n := constant.ToInt(y)
switch {
case constant.Sign(x) == 0 && constant.Sign(y) < 0:
return nil, ErrDivisionByZero
case constant.Sign(x) >= 0:
return nonNegativePower(x, y, n)
case n.Kind() != constant.Int:
return nil, ErrNoRealResult
}
// x is negative and n whole: x^n is (-x)^n, negated if n is odd.
v, err := nonNegativePower(constant.UnaryOp(token.SUB, x, 0), y, n)
if err != nil {
return nil, err
}
odd := constant.BinaryOp(n, token.AND, constant.MakeInt64(1))
if constant.Sign(odd) != 0 {
v = constant.UnaryOp(token.SUB, v, 0)
}
return v, nil
}
func literal(n *ast.BasicLit) (constant.Value, error) {
if n.Kind != token.INT && n.Kind != token.FLOAT {
return nil, ErrNotArithmetic
// nonNegativePower computes x^y for x of at least zero, and y not below
// zero if x is zero: exactly if y is a whole number n and go/constant
// holds the result exactly, otherwise in float64.
func nonNegativePower(x, y, n constant.Value) (constant.Value, error) {
e, ok := constant.Int64Val(n)
if ok && -maxExactExponent <= e && e <= maxExactExponent {
v := exactPower(x, e)
if exact(v) {
return v, nil
}
}
if !decimalLiteral.MatchString(n.Value) {
return nil, ErrNotArithmetic
// y is above zero here if x is zero.
if constant.Sign(x) == 0 {
return x, nil
}
// Read as FLOAT whatever the token says, which makes every literal
// decimal: as INT, a leading zero would make it octal.
v := constant.MakeFromLiteral(n.Value, token.FLOAT, 0)
xf, _ := constant.Float64Val(x)
yf, _ := constant.Float64Val(y)
// The syntax was checked above, so Unknown here means the exponent
// overflowed.
if v.Kind() == constant.Unknown {
return nil, ErrTooLarge
// x^y is (√x)^(2y). An x outside the normal range of a double, such
// as 2^1200, would lose digits as a double, or all of them, so square
// roots taken from its exact value bring it into that range first. As
// x is between 2^-4096 and 2^4096 (see exact), three at most are
// needed.
r, _ := constant.Val(x).(*big.Rat)
root := new(big.Float).SetPrec(floatPrecision).SetRat(r)
for !normal(xf) {
root.Sqrt(root)
xf, _ = root.Float64()
yf *= 2
}
return v, nil
// x^y is not zero. If it is not a normal double, it has lost digits,
// or all of them.
f := math.Pow(xf, yf)
if !normal(f) {
return nil, ErrOutOfRange
}
return constant.MakeFloat64(f), nil
}
// exactPower computes x^e by repeated squaring. x is not zero if e is
// negative. It starts from 1 as a fraction, a Float to go/constant, so
// that x^0 is a fraction like every other number (see exact). Each
// step's numbers stay small: go/constant holds one whose numerator or
// denominator reaches 4096 bits as a 512-bit float.
func exactPower(x constant.Value, e int64) constant.Value {
one := constant.MakeFloat64(1)
result := one
for n := max(e, -e); n > 0; n >>= 1 {
if n&1 == 1 {
result = constant.BinaryOp(result, token.MUL, x)
}
x = constant.BinaryOp(x, token.MUL, x)
}
if e < 0 {
result = constant.BinaryOp(one, token.QUO, result)
}
return result
}
// exact reports whether v is a fraction whose numerator and denominator
// are both below bitLimit bits, as every number here must be, so that
// each step of arithmetic stays small. go/constant never rounds an
// integer, however large, so every number is made a fraction: literals
// are read as FLOAT, and a power starts from the fraction 1. It rounds
// a fraction that grows past the limit, to a 512-bit float and past
// that float's range to Unknown, but not one it reads from a literal,
// such as 1e-1233, so the limit is checked here.
//
// A number that is not exact is refused wherever it appears: a sum can
// lose the answer entirely (7^1000*7^1000 + 5 - 7^1000*7^1000 would be
// 0), and a remainder, or whether an exponent is whole or odd, cannot be
// read from one.
func exact(v constant.Value) bool {
r, ok := constant.Val(v).(*big.Rat)
return ok && r.Num().BitLen() < bitLimit && r.Denom().BitLen() < bitLimit
}
// normal reports whether f is a normal double, finite and at least
// smallestNormal in magnitude: a number other than zero keeps all of a
// double's digits only as one.
func normal(f float64) bool {
abs := math.Abs(f)
return abs >= smallestNormal && abs <= math.MaxFloat64
}
// format writes a result for a person to read. A whole number of
// ordinary size is written exactly, digit for digit; anything else goes
// through float64, whose shortest round-trip form is free of the noise
// (0.30000000000000004) that printing a binary fraction to a fixed
// precision produces.
func format(v constant.Value) (string, error) {
// ordinary size is written exactly, digit for digit. Any other result in
// the normal range of a double goes through float64, whose shortest
// round-trip form is free of the noise (0.30000000000000004) that
// printing a binary fraction to a fixed precision produces. Past that
// range a double keeps fewer digits, or none (2^-1074 would be written
// 5e-324, and 2^1024 is infinite), so such a result is written from its
// exact value, to significantDigits.
func format(v constant.Value) string {
f, _ := constant.Float64Val(v)
if math.IsInf(f, 0) || math.IsNaN(f) {
return "", ErrTooLarge
if constant.Sign(v) != 0 && !normal(f) {
// Every number here is exact: see exact.
r, _ := constant.Val(v).(*big.Rat)
return new(big.Float).SetPrec(floatPrecision).SetRat(r).Text('g', significantDigits)
}
abs := math.Abs(f)
if i := constant.ToInt(v); i.Kind() == constant.Int && abs < plainUpper {
return i.ExactString(), nil
return i.ExactString()
}
if abs >= plainUpper || abs < plainLower {
return strconv.FormatFloat(f, 'g', -1, 64), nil
return strconv.FormatFloat(f, 'g', -1, 64)
}
return strconv.FormatFloat(f, 'f', -1, 64), nil
return strconv.FormatFloat(f, 'f', -1, 64)
}
+279 -11
View File
@@ -4,6 +4,7 @@ import (
"errors"
"strings"
"testing"
"time"
"sneak.berlin/go/simplexcalc/internal/calc"
)
@@ -13,7 +14,7 @@ import (
func TestEvaluate(t *testing.T) {
t.Parallel()
cases := map[string]string{
expectResults(t, map[string]string{
// The specification's own examples.
"2 + 2": "4",
"5 * 5/2": "12.5",
@@ -51,7 +52,138 @@ func TestEvaluate(t *testing.T) {
"1234567.5": "1234567.5",
"-1 / 4": "-0.25",
"1e300 * 1e8": "1e+308",
}
// Past the normal range of a double, written from the exact value
// to 17 significant digits, trailing zeros dropped.
"1e400": "1e+400",
"1e300 * 1e300": "1e+600",
"1 / 2e-400": "5e+399",
"-1e-310": "-1e-310",
"1 / 3e400": "3.3333333333333333e-401",
})
}
// TestEvaluatePowers: ^ and ** are one operator, binding tighter than
// * / % and a sign on its left, and grouping to the right.
func TestEvaluatePowers(t *testing.T) {
t.Parallel()
expectResults(t, map[string]string{
"2^3": "8",
"2**3": "8",
"2 ** 3 ^ 2": "512",
"2^3^2": "512",
"(2^3)^2": "64",
"-2^2": "-4",
"(-2)^2": "4",
"(-2)^3": "-8",
"(-2)^-3": "-0.125",
"2^-1": "0.5",
"2**-1": "0.5",
"-2^-2": "-0.25",
"2^-3^2": "0.001953125",
"2 * 3^2": "18",
"3^2 * 2": "18",
"2^3 / 2^2": "2",
"1 + 2^3 - 3^2": "0",
"010^2": "100",
"0.1^2": "0.01",
"2^100 - 2^100 + 1": "1",
"2^64": "18446744073709551616",
"2^100": "1.2676506002282294e+30",
"0^0": "1",
"0^3": "0",
"1.5^2": "2.25",
"2^0.5": "1.4142135623730951",
"-2^0.5": "-1.4142135623730951",
"4^0.5": "2",
"0^0.5": "0",
"2^1023": "8.98846567431158e+307",
"2^-1022": "2.2250738585072014e-308",
// Past 2^53 a float64 cannot tell odd from even.
"(-1)^(2^53 + 1)": "-1",
"(-1)^(10^30)": "1",
"(-1)^-9223372036854775808": "1",
// Whole powers beyond the range of a double, held exactly.
"2^-1400 * 2^1365 * 2^35": "1",
"0.3^900 * 10^470": "0.25652473503365386",
"2^1500 / 2^1000": "3.273390607896142e+150",
"(2^1200)/(2^1199)": "2",
"2^1200 % 7": "1",
// Results past the range of a double, written to 17 significant
// digits, up to the largest power of 2 under the 4096-bit limit.
"2^1200": "1.7218479456385751e+361",
"2**1200": "1.7218479456385751e+361",
"2^1024": "1.7976931348623159e+308",
"10^400": "1e+400",
"2^-1074": "4.9406564584124654e-324",
"2^-1200": "5.8077137562175032e-362",
"2^-1400": "3.6141491434385841e-422",
"0.5^1100": "7.3621518290228627e-332",
"1.5^2000": "1.5223626185737825e+352",
"(1/3)^-2000": "1.7478712517226516e+954",
"2^1200 - 2^1199": "8.6092397281928753e+360",
"-2^1201": "-3.4436958912771501e+361",
"(-2)^1201": "-3.4436958912771501e+361",
"2^2000 * 2^2000": "1.3182040934309431e+1204",
"10^1232": "1e+1232",
"2^4094": "2.6109722035328813e+1232",
// A power computed in float64 carries its rounding into the exact
// arithmetic after it, past the range of a double as within it.
"2^0.5 * 1e400": "1.4142135623730951e+400",
// A fractional power of an exact number outside the range of a
// double, taken from its exact value, up to the edges of that
// range.
"(2^1200)^0.5": "4.149515568880993e+180",
"(2^1024)^0.5": "1.3407807929942597e+154",
"(2^-1200)^0.5": "2.409919865102884e-181",
"1e400^0.5": "1e+200",
"1e-400^0.5": "1e-200",
"1e-310^0.5": "1e-155",
"(2^1200)^-0.5": "2.409919865102884e-181",
"(2^1200)^0.5 / 2^600": "1",
"1e400^-0.001": "0.39810717055349726",
"1e-400^0.001": "0.39810717055349726",
"(2^2047)^0.5": "1.2711610061536464e+308",
"(2^-2044)^0.5": "2.2250738585072014e-308",
})
}
// TestEvaluateModulo: % sits with * and /, left to right, and its result
// takes the sign of the divisor.
func TestEvaluateModulo(t *testing.T) {
t.Parallel()
expectResults(t, map[string]string{
"7 % 3": "1",
"-7 % 3": "2",
"7 % -3": "-2",
"-7 % -3": "-1",
"6 % 3": "0",
"-6 % 3": "0",
"7.5 % 2": "1.5",
"0.3 % 0.1": "0",
"-0.3 % 0.2": "0.1",
"10 % 4 * 3": "6",
"2 * 7 % 4": "2",
"1 + 7 % 3": "2",
"2^10 % 7": "2",
"10^400 % 7": "4",
"1e-30 % 1": "1e-30",
"-1e-30 % 1": "1",
"10 / 8 % 1": "0.25",
"(7 % 3)^2": "1",
"7 % (3 ^ 2)": "7",
// Both operands and their quotient are held exactly, but y times
// the whole part of x/y is too large to be.
"(5^860*3^630/7) % (5^860/2^998/2^998)": "0.5179219763783696",
// A whole number made from x^0, just below the 4096-bit limit.
"(3^0 + 3^0 + 3^0)^2583 % 10": "7",
})
}
// expectResults checks that each expression evaluates to its result.
func expectResults(t *testing.T, cases map[string]string) {
t.Helper()
for in, want := range cases {
t.Run(in, func(t *testing.T) {
@@ -70,11 +202,11 @@ func TestEvaluate(t *testing.T) {
}
// TestEvaluateRefuses covers what must be answered with an error rather
// than a number, and never with a panic.
// than a number.
func TestEvaluateRefuses(t *testing.T) {
t.Parallel()
cases := map[string]error{
expectErrors(t, map[string]error{
"": calc.ErrNotArithmetic,
" ": calc.ErrNotArithmetic,
"hello": calc.ErrNotArithmetic,
@@ -89,20 +221,91 @@ func TestEvaluateRefuses(t *testing.T) {
"2i * 2i": calc.ErrNotArithmetic,
"0x10 + 1": calc.ErrNotArithmetic,
"1_000 + 1": calc.ErrNotArithmetic,
"7 % 2": calc.ErrNotArithmetic,
"2 ^ 3": calc.ErrNotArithmetic,
"1 << 10": calc.ErrNotArithmetic,
"1 == 1": calc.ErrNotArithmetic,
"!1": calc.ErrNotArithmetic,
"func() int { return 1 }()": calc.ErrNotArithmetic,
"(1 + 2": calc.ErrNotArithmetic,
"1 + 2)": calc.ErrNotArithmetic,
"()": calc.ErrNotArithmetic,
"(2)(3)": calc.ErrNotArithmetic,
"2 ^": calc.ErrNotArithmetic,
"^ 2": calc.ErrNotArithmetic,
"2 ^^ 3": calc.ErrNotArithmetic,
"2 *** 3": calc.ErrNotArithmetic,
"2 * * 3": calc.ErrNotArithmetic,
"% 3": calc.ErrNotArithmetic,
"50%": calc.ErrNotArithmetic,
"2 × 3": calc.ErrNotArithmetic,
"1 / 0": calc.ErrDivisionByZero,
"1 / (2 - 2)": calc.ErrDivisionByZero,
"5 / 0.0": calc.ErrDivisionByZero,
"1e400": calc.ErrTooLarge,
"1e300 * 1e300": calc.ErrTooLarge,
"1e999999999 * 1e999999999": calc.ErrTooLarge,
"1 / 1e-400": calc.ErrTooLarge,
}
"7 % 0": calc.ErrDivisionByZero,
"7.5 % (1 - 1)": calc.ErrDivisionByZero,
"0^-1": calc.ErrDivisionByZero,
"0^-0.5": calc.ErrDivisionByZero,
"(-2)^0.5": calc.ErrNoRealResult,
"(-8)^(1/3)": calc.ErrNoRealResult,
"(-1)^-0.5": calc.ErrNoRealResult,
})
}
// TestEvaluateOutOfRange: a number is held exactly, or computed in
// float64 as a normal double. Anything else is refused.
func TestEvaluateOutOfRange(t *testing.T) {
t.Parallel()
expectErrors(t, map[string]error{
// Just past the 4096-bit limit, which 2^4094 and 10^1232 are
// under, and far past it.
"2^4095": calc.ErrOutOfRange,
"-2^4095": calc.ErrOutOfRange,
"2^-4095": calc.ErrOutOfRange,
"10^1233": calc.ErrOutOfRange,
"2^4094 * 2": calc.ErrOutOfRange,
"1e999999999 * 1e999999999": calc.ErrOutOfRange,
"2^5000": calc.ErrOutOfRange,
"(-2)^5001": calc.ErrOutOfRange,
"0.5^-5000": calc.ErrOutOfRange,
// Powers computed in float64 whose result is not a normal double,
// and so has lost digits, or all of them, whatever the size of
// the base.
"2^-1073.5 * 2^1073": calc.ErrOutOfRange,
"2^1500.5": calc.ErrOutOfRange,
"(2^1200)^0.9": calc.ErrOutOfRange,
"1e-400^0.9": calc.ErrOutOfRange,
"(2^2048)^0.5": calc.ErrOutOfRange,
"(2^-2046)^0.5": calc.ErrOutOfRange,
"(0.5^1100)^4 / (0.5^1100)^4": calc.ErrOutOfRange,
"(1/3)^1e400": calc.ErrOutOfRange,
// go/constant holds numbers of this size rounded. A sum of them
// can lose the answer (this one would be 0), and so can a
// remainder or the sign of -1 to such a power.
"7^1000 * 7^1000 + 5 - 7^1000 * 7^1000": calc.ErrOutOfRange,
"7^1000 * 7^1000 / 7^1000 % 10": calc.ErrOutOfRange,
"(-1)^(3^1365 * 3^1365 / 3^1365)": calc.ErrOutOfRange,
"(-1)^1e1300": calc.ErrOutOfRange,
"1e-1300": calc.ErrOutOfRange,
"1e-1300 + 1": calc.ErrOutOfRange,
"1e-700 * 1e-700": calc.ErrOutOfRange,
"0.1^800 * 0.1^800": calc.ErrOutOfRange,
// Both operands are held exactly, but their quotient is not.
"3^1365 % 7^-1000": calc.ErrOutOfRange,
// The same limit for a whole number made from x^0, which
// go/constant would hold as an integer and never round, and for
// a literal it reads exactly as a fraction past the limit.
"(2^0 + 2^0)^4095 % 10": calc.ErrOutOfRange,
"1e-1233 * 0": calc.ErrOutOfRange,
// go/constant reads this literal as 0.
"1e-999999999": calc.ErrOutOfRange,
"1 / 1e-999999999": calc.ErrOutOfRange,
})
}
// expectErrors checks that each expression is refused with its error,
// and never with a panic.
func expectErrors(t *testing.T, cases map[string]error) {
t.Helper()
for in, want := range cases {
t.Run(in, func(t *testing.T) {
@@ -116,6 +319,71 @@ func TestEvaluateRefuses(t *testing.T) {
}
}
// TestEvaluateBoundsWork: computed exactly, each of these powers would
// need more time and memory than any machine has. They must be answered
// at once.
func TestEvaluateBoundsWork(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
err error
}{
{in: "9^9^9^9^9", err: calc.ErrOutOfRange},
{in: "((9^999)^999)^999", err: calc.ErrOutOfRange},
{in: "(3^2583)^4096", err: calc.ErrOutOfRange},
{in: "1.0000001^99999", want: "1.01005006557947"},
{in: "0.5^99999999999999999999", err: calc.ErrOutOfRange},
{in: "2^-9223372036854775808", err: calc.ErrOutOfRange},
{in: "(-1)^99999999999999999999", want: "-1"},
// The longest tower that fits.
{in: strings.Repeat("9^", 127) + "9", err: calc.ErrOutOfRange},
// The largest power of 3 computed exactly, as often as fits.
{in: "0" + strings.Repeat("*3^2583", 36), want: "0"},
// Whole numbers made from x^0, through each operation. Held as
// integers, which go/constant never rounds, they would escape
// the 4096-bit limit: the first needs about 69 billion bits.
{in: "(((2^0+2^0)^4096)^4096)^4096", err: calc.ErrOutOfRange},
{in: "(((0^0+0^0)^4096)^4096)^4096", err: calc.ErrOutOfRange},
{in: "(((-2^0-2^0)^4096)^4096)^4096", err: calc.ErrOutOfRange},
{in: "((2^0+2^0)^4000*(2^0+2^0)^4000)^4096", err: calc.ErrOutOfRange},
{in: "((((2^0+2^0)/2^0)^4096)^4096)^4096", err: calc.ErrOutOfRange},
{in: "((((2^0+2^0) % 3)^4096)^4096)^4096", err: calc.ErrOutOfRange},
{in: "(((2^0+2^0)^4096)^4096)^4096 * 0", err: calc.ErrOutOfRange},
// A fraction whose numerator and denominator are both just below
// the limit, and a literal whose exponent is too large to read.
{in: "(3^2583/5^1760)^4096", err: calc.ErrOutOfRange},
{in: "1e99999999999999999999", err: calc.ErrOutOfRange},
// Results just below the limit, written from their exact value.
{in: "2^4094", want: "2.6109722035328813e+1232"},
{in: "3^2583", want: "2.5363018640659988e+1232"},
{in: "2^-4094", want: "3.8299909843808741e-1233"},
{in: "-1/3^2583", want: "-3.9427483540814775e-1233"},
// Fractional powers of numbers just below the limit, whose bases
// take the most square roots to bring into the range of a double.
{in: "(2^-4094)^0.125", want: "8.869511863657883e-155"},
{in: "(1/3^2583)^0.5", err: calc.ErrOutOfRange},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
t.Parallel()
start := time.Now()
got, err := calc.Evaluate(c.in)
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("Evaluate(%q) took %v", c.in, elapsed)
}
if !errors.Is(err, c.err) || got != c.want {
t.Errorf("Evaluate(%q) = %q, %v; want %q, %v", c.in, got, err, c.want, c.err)
}
})
}
}
// TestEvaluateCapsInput: the length cap is what bounds the work a
// message can cause, so it must hold exactly at the boundary.
func TestEvaluateCapsInput(t *testing.T) {
+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 (
"fmt"
@@ -11,7 +11,7 @@ import (
// environment (see internal/config), not from flags: the bot runs in a
// container, where the environment is the interface, and one source of
// truth means there is no precedence rule to get wrong.
func rootCmd() *cobra.Command {
func rootCmd(version string) *cobra.Command {
root := &cobra.Command{
Use: appname,
Short: appname + " — a SimpleX Chat bot that answers arithmetic",
@@ -26,12 +26,12 @@ func rootCmd() *cobra.Command {
SilenceErrors: true,
}
root.AddCommand(runCmd(), versionCmd())
root.AddCommand(runCmd(version), versionCmd(version))
return root
}
func versionCmd() *cobra.Command {
func versionCmd(version string) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "print the version and exit",
@@ -1,4 +1,4 @@
package main
package cli
import (
"context"
@@ -14,13 +14,13 @@ import (
"sneak.berlin/go/simplexcalc/internal/logger"
)
func runCmd() *cobra.Command {
func runCmd(version string) *cobra.Command {
return &cobra.Command{
Use: "run",
Short: "run the bot",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return run(cmd.Context())
return run(cmd.Context(), version)
},
}
}
@@ -28,7 +28,7 @@ func runCmd() *cobra.Command {
// run reads the configuration and runs the bot until SIGINT or SIGTERM,
// or until it fails. A failure is the process's exit status, so a
// supervisor restarts it.
func run(ctx context.Context) error {
func run(ctx context.Context, version string) error {
cfg, err := config.New()
if err != nil {
return fmt.Errorf("reading configuration: %w", err)
@@ -45,7 +45,7 @@ func run(ctx context.Context) error {
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop()
err = bot.Run(ctx, log, cfg.DataDir)
err = bot.Run(ctx, log, cfg, bot.ChatPort)
if err != nil {
log.Error("stopped", "error", err)
+79 -8
View File
@@ -16,8 +16,10 @@ package config
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"unicode/utf8"
"github.com/spf13/viper"
@@ -34,12 +36,23 @@ import (
// Environment variable names. Bare names, no prefix: this matches the
// other services and keeps a compose file readable.
const (
EnvDataDir = "DATA_DIR"
EnvDebug = "DEBUG"
EnvDataDir = "DATA_DIR"
EnvDebug = "DEBUG"
EnvPort = "PORT"
EnvAPITokenFile = "API_TOKEN_FILE" //nolint:gosec // G101: a name, not a credential
)
// DefaultDataDir applies when DATA_DIR is absent.
const DefaultDataDir = "./data"
// Defaults, for the variables that are absent.
const (
DefaultDataDir = "./data"
DefaultPort = 8080
)
// MinAPITokenLength is the fewest characters the API credential may
// have, not counting whitespace around it.
const MinAPITokenLength = 32
const maxPort = 65535
// ErrInvalidConfig is the sentinel every configuration failure wraps,
// so callers can distinguish "the operator got it wrong" from "the
@@ -51,10 +64,19 @@ var ErrInvalidConfig = errors.New("invalid configuration")
// later, so there is exactly one moment at which configuration can be
// wrong, and it is before anything starts.
type Config struct {
// DataDir holds the SimpleX Chat database: the bot's profile, its
// address and its contacts. Losing it loses the address.
// DataDir holds the SimpleX Chat database, with the bot's profile,
// its address and its contacts, and the API's webhooks. Losing it
// loses the address.
DataDir string
Debug bool
// Port is the API's TCP port.
Port int
// APIToken is the credential every API request must carry, read
// from the file named by API_TOKEN_FILE. Empty when that is absent,
// and then the API refuses every request. Never log it.
APIToken string
}
// loader parses one environment into a Config, accumulating every
@@ -109,6 +131,53 @@ func (l *loader) boolean(key string, def bool) bool {
return b
}
// port accepts a whole number from 1 to 65535 and refuses everything
// else.
func (l *loader) port(key string, def int) int {
s, ok := l.raw(key)
if !ok {
return def
}
n, err := strconv.Atoi(s)
if err != nil || n < 1 || n > maxPort {
l.fail(key, s, "not a port (use a whole number from 1 to 65535)")
return def
}
return n
}
// tokenFile returns the credential held in the file named by key, with
// the whitespace around it trimmed, or "" when key is absent. A file
// that cannot be read, or holds fewer than MinAPITokenLength
// characters, is a failure; the message names the file, never what it
// holds.
func (l *loader) tokenFile(key string) string {
path, ok := l.raw(key)
if !ok {
return ""
}
b, err := os.ReadFile(path) //nolint:gosec // G304: the operator names the file.
if err != nil {
l.fail(key, path, "unreadable: "+err.Error())
return ""
}
token := strings.TrimSpace(string(b))
if utf8.RuneCountInString(token) < MinAPITokenLength {
l.fail(key, path, fmt.Sprintf("a file holding fewer than %d characters",
MinAPITokenLength))
return ""
}
return token
}
// New parses and validates the environment. An error here aborts
// startup before the chat client is launched, so there is no partially
// configured running state to reason about.
@@ -125,8 +194,10 @@ func load(v *viper.Viper) (*Config, error) {
l := &loader{v: v}
c := &Config{
DataDir: l.str(EnvDataDir, DefaultDataDir),
Debug: l.boolean(EnvDebug, false),
DataDir: l.str(EnvDataDir, DefaultDataDir),
Debug: l.boolean(EnvDebug, false),
Port: l.port(EnvPort, DefaultPort),
APIToken: l.tokenFile(EnvAPITokenFile),
}
if len(l.errs) > 0 {
+92 -2
View File
@@ -2,6 +2,9 @@ package config_test
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/viper"
@@ -26,8 +29,11 @@ func TestAbsentValuesTakeDefaults(t *testing.T) {
t.Parallel()
for name, kv := range map[string]map[string]string{
"unset": nil,
"whitespace only": {config.EnvDataDir: " ", config.EnvDebug: " "},
"unset": nil,
"whitespace only": {
config.EnvDataDir: " ", config.EnvDebug: " ",
config.EnvPort: " ", config.EnvAPITokenFile: "\t",
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
@@ -44,6 +50,14 @@ func TestAbsentValuesTakeDefaults(t *testing.T) {
if c.Debug {
t.Error("Debug must default off")
}
if c.Port != config.DefaultPort {
t.Errorf("Port = %d, want %d", c.Port, config.DefaultPort)
}
if c.APIToken != "" {
t.Error("APIToken must default to none")
}
})
}
}
@@ -94,3 +108,79 @@ func TestValidValuesAreUsed(t *testing.T) {
t.Error("Debug = false, want true")
}
}
// TestPort: PORT is a whole number from 1 to 65535, and anything else
// aborts.
func TestPort(t *testing.T) {
t.Parallel()
for raw, want := range map[string]int{"1": 1, "8081": 8081, "65535": 65535} {
c, err := config.Load(env(map[string]string{config.EnvPort: raw}))
if err != nil {
t.Errorf("PORT=%q was rejected: %v", raw, err)
continue
}
if c.Port != want {
t.Errorf("PORT=%q: Port = %d, want %d", raw, c.Port, want)
}
}
for _, raw := range []string{"0", "65536", "-1", "80.5", "8080x", "http"} {
_, err := config.Load(env(map[string]string{config.EnvPort: raw}))
if !errors.Is(err, config.ErrInvalidConfig) {
t.Errorf("PORT=%q: error = %v, want ErrInvalidConfig", raw, err)
}
}
}
// TestAPITokenFile: the credential is the file's content without the
// whitespace around it. A file that cannot be read, or holds too short
// a credential, aborts, and the error never shows what the file holds.
func TestAPITokenFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
token := strings.Repeat("k", config.MinAPITokenLength)
short := strings.Repeat("s", config.MinAPITokenLength-1)
for name, content := range map[string]string{
"good": " " + token + "\n",
"short": "\n" + short + " \n",
} {
err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)
if err != nil {
t.Fatal(err)
}
}
load := func(name string) (*config.Config, error) {
return config.Load(env(map[string]string{
config.EnvAPITokenFile: filepath.Join(dir, name),
}))
}
c, err := load("good")
if err != nil {
t.Fatalf("a good file was rejected: %v", err)
}
if c.APIToken != token {
t.Errorf("APIToken = %q, want %q", c.APIToken, token)
}
_, err = load("missing")
if !errors.Is(err, config.ErrInvalidConfig) {
t.Errorf("a missing file: error = %v, want ErrInvalidConfig", err)
}
_, err = load("short")
if !errors.Is(err, config.ErrInvalidConfig) {
t.Fatalf("a short credential: error = %v, want ErrInvalidConfig", err)
}
if strings.Contains(err.Error(), short) {
t.Errorf("the error shows the file's content: %v", err)
}
}
+109 -4
View File
@@ -14,6 +14,7 @@ import (
"errors"
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"sync"
@@ -21,15 +22,33 @@ import (
"github.com/gorilla/websocket"
)
// maxMessageSize bounds one message from the chat client. The largest
// thing it sends is a record carrying a contact's profile picture, well
// under this.
// maxMessageSize bounds one message from the chat client; a larger one
// ends the connection. The largest it sends are the pages of chat items
// ChatItems asks for, which chatItemsPage keeps under this.
const maxMessageSize = 16 << 20
// chatItemsPage is how many chat items ChatItems asks for at a time. An
// item repeats a message's text and spells out its formatting, which for
// text made of short mentions makes it 26 times as long as the text, and
// a contact's message can hold 64 KiB: one item can take 1.7 MiB. Five
// stay well under maxMessageSize.
const chatItemsPage = 5
var (
// ErrClosed is returned by Command once the connection has ended.
ErrClosed = errors.New("connection to the chat client closed")
// ErrNoContact is returned for a contact the user does not have.
ErrNoContact = errors.New("no such contact")
// ErrContactNotReady is returned for sending to a contact who cannot
// receive messages: one who has deleted their chat with the user, or
// who has not finished connecting.
ErrContactNotReady = errors.New("the contact cannot receive messages")
// ErrMessageTooLarge is returned for a message too large to send.
ErrMessageTooLarge = errors.New("the message is too large")
errUnexpected = errors.New("unexpected response")
errCommand = errors.New("command failed")
)
@@ -168,6 +187,55 @@ func (c *Client) SetAddressSettings(
return c.command(ctx, cmd, TypeUserContactLinkUpdated, nil)
}
// Contacts returns the user's contacts: everyone it has a direct chat
// with.
func (c *Client) Contacts(ctx context.Context, userID int64) ([]Contact, error) {
var r struct {
Contacts []Contact `json:"contacts"`
}
err := c.command(ctx, cmdListContacts(userID), TypeContactsList, &r)
return r.Contacts, err
}
// ChatItems returns the last count items of the chat with a contact,
// oldest first, or all of them if the chat has fewer.
func (c *Client) ChatItems(
ctx context.Context, contactID int64, count int,
) ([]ChatItem, error) {
var (
items []ChatItem
before int64 // 0 asks for the chat's last items
)
for len(items) < count {
page := min(count-len(items), chatItemsPage)
//nolint:tagliatelle // the chat client's wire format.
var r struct {
Chat struct {
ChatItems []ChatItem `json:"chatItems"`
} `json:"chat"`
}
err := c.command(ctx, cmdGetChat(contactID, before, page), TypeAPIChat, &r)
if err != nil {
return nil, err
}
items = slices.Concat(r.Chat.ChatItems, items)
if len(r.Chat.ChatItems) < page {
break
}
before = items[0].Meta.ItemID
}
return items, nil
}
// SendText sends a text message to a contact, as a reply to the message
// quotedItemID (0 for none). It does not wait for the chat client to
// accept it; a failure is logged when the client's answer arrives.
@@ -184,6 +252,32 @@ func (c *Client) SendText(contactID, quotedItemID int64, text string) error {
return c.write(id, cmd)
}
// SendMessage sends a text message to a contact and returns it as the
// chat client recorded it. Unlike SendText, it waits for the chat
// client's answer, so an EventHandler must never call it.
func (c *Client) SendMessage(
ctx context.Context, contactID int64, text string,
) (ChatItem, error) {
cmd, err := cmdSendText(contactID, 0, text)
if err != nil {
return ChatItem{}, err
}
var r NewChatItems
err = c.command(ctx, cmd, TypeNewChatItems, &r)
if err != nil {
return ChatItem{}, err
}
if len(r.ChatItems) != 1 {
return ChatItem{}, fmt.Errorf("%w to %q: %d chat items",
errUnexpected, cmdName(cmd), len(r.ChatItems))
}
return r.ChatItems[0].ChatItem, nil
}
// CommandError is a command the chat client refused. Type and Detail
// are the discriminators of its chatError record, such as "errorStore"
// and "userContactLinkNotFound".
@@ -329,6 +423,8 @@ func (c *Client) dispatch(data []byte) {
}
}
// commandError returns the error in a chatCmdError record, marked with
// this package's error for the refusals that have one.
func commandError(ev Event) error {
var r cmdError
@@ -347,7 +443,16 @@ func commandError(ev Event) error {
}
}
return e
switch e.Detail {
case "contactNotFound":
return fmt.Errorf("%w: %w", ErrNoContact, e)
case "contactNotReady":
return fmt.Errorf("%w: %w", ErrContactNotReady, e)
case "largeMsg":
return fmt.Errorf("%w: %w", ErrMessageTooLarge, e)
default:
return e
}
}
// cmdName is a command without its arguments, for error messages: the
+56 -5
View File
@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
@@ -43,12 +44,25 @@ const (
contactConnected = `{"type":"contactConnected","user":{"userId":1},
"contact":{"contactId":3,"localDisplayName":"alice"}}`
// Two contacts with the same display name, which the chat client
// tells apart by the local name it gives the second. The second has
// deleted its chat with the bot, and is still listed.
contactsList = `{"type":"contactsList","user":{"userId":1},"contacts":[
{"contactId":3,"localDisplayName":"tester","profile":{"profileId":3,
"displayName":"tester","fullName":"","localAlias":""},
"activeConn":{"connId":2,"connStatus":{"type":"ready"}},
"contactUsed":true,"contactStatus":"active","chatDeleted":false},
{"contactId":4,"localDisplayName":"tester_1","profile":{"profileId":4,
"displayName":"tester","fullName":"","localAlias":""},
"activeConn":{"connId":3,"connStatus":{"type":"deleted"}},
"contactUsed":true,"contactStatus":"deleted","chatDeleted":false}]}`
)
// fakeChat stands in for the chat client's API. It answers each command
// with the record in replies under the command's first word, stays
// silent for a command it has no record for, and reports every command
// it receives on got.
// with the record in replies under the whole command or else under its
// first word, stays silent for a command it has no record for, and
// reports every command it receives on got.
type fakeChat struct {
replies map[string]string
got chan string
@@ -98,8 +112,13 @@ func (f *fakeChat) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.got <- cmd.Cmd
name, _, _ := strings.Cut(cmd.Cmd, " ")
if resp, ok := f.replies[name]; ok {
resp, ok := f.replies[cmd.Cmd]
if !ok {
name, _, _ := strings.Cut(cmd.Cmd, " ")
resp, ok = f.replies[name]
}
if ok {
f.send(cmd.CorrID, resp)
}
}
@@ -220,6 +239,38 @@ func TestAddressSetup(t *testing.T) {
}
}
// TestContacts: the contacts of the given user come back with their
// ids, display names and whether they are deleted.
func TestContacts(t *testing.T) {
t.Parallel()
f, url := newFakeChat(t, map[string]string{"/_contacts": contactsList})
c, ctx := dial(t, url, nil)
contacts, err := c.Contacts(ctx, 1)
if err != nil {
t.Fatalf("Contacts: %v", err)
}
if got := f.next(t); got != "/_contacts 1" {
t.Errorf("command = %s, want /_contacts 1", got)
}
tester := simplex.Profile{DisplayName: "tester"}
want := []simplex.Contact{
{ContactID: 3, Profile: tester, Status: "active"},
{ContactID: 4, Profile: tester, Status: "deleted"},
}
if !slices.Equal(contacts, want) {
t.Fatalf("Contacts = %+v\nwant %+v", contacts, want)
}
if contacts[0].Deleted() || !contacts[1].Deleted() {
t.Error("Deleted must be false for contact 3 and true for contact 4")
}
}
// TestRefusedCommand: a command the chat client refuses is an error
// that names the reason.
func TestRefusedCommand(t *testing.T) {
+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)
}
})
}
}
+65 -19
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"strconv"
"time"
)
// Response and event types this package and the bot act on. The chat
@@ -14,6 +15,8 @@ const (
TypeUserContactLink = "userContactLink"
TypeUserContactLinkCreated = "userContactLinkCreated"
TypeUserContactLinkUpdated = "userContactLinkUpdated"
TypeContactsList = "contactsList"
TypeAPIChat = "apiChat"
TypeNewChatItems = "newChatItems"
TypeContactConnected = "contactConnected"
TypeChatCmdError = "chatCmdError"
@@ -46,10 +49,14 @@ func (e Event) Decode(v any) error {
type (
// User is the chat client's local user profile: the bot itself.
User struct {
UserID int64 `json:"userId"`
Profile struct {
DisplayName string `json:"displayName"`
} `json:"profile"`
UserID int64 `json:"userId"`
Profile Profile `json:"profile"`
}
// Profile is how the bot or a contact presents itself. Nothing
// makes a display name unique.
Profile struct {
DisplayName string `json:"displayName"`
}
// ConnLink is a SimpleX link. The short form is what people share;
@@ -61,11 +68,14 @@ type (
// Contact is a person connected to the bot.
Contact struct {
ContactID int64 `json:"contactId"`
ContactID int64 `json:"contactId"`
Profile Profile `json:"profile"`
Status string `json:"contactStatus"`
}
// NewChatItems is the record of a newChatItems event: messages
// received, or sent from this profile elsewhere.
// NewChatItems is the record of a newChatItems event, messages
// received or sent from this profile elsewhere, and of the answer
// to sending a message.
NewChatItems struct {
ChatItems []AChatItem `json:"chatItems"`
}
@@ -75,25 +85,36 @@ type (
Contact Contact `json:"contact"`
}
// AChatItem is one message together with the chat it belongs to.
// AChatItem is one chat item together with the chat it belongs to.
AChatItem struct {
ChatInfo struct {
Type string `json:"type"`
Contact *Contact `json:"contact,omitempty"`
} `json:"chatInfo"`
ChatItem struct {
ChatDir tagged `json:"chatDir"`
Meta struct {
ItemID int64 `json:"itemId"`
} `json:"meta"`
Content struct {
Type string `json:"type"`
MsgContent *MsgContent `json:"msgContent,omitempty"`
} `json:"content"`
} `json:"chatItem"`
ChatItem ChatItem `json:"chatItem"`
}
// MsgContent is a message body. Only "text" is sent or read here.
// ChatItem is one item in a chat: a message, or an event the chat
// client records there, such as the contact connecting. Content.Type
// tells them apart: "rcvMsgContent" and "sndMsgContent" are messages
// received and sent.
ChatItem struct {
ChatDir tagged `json:"chatDir"`
Meta struct {
ItemID int64 `json:"itemId"`
// ItemTs is when a received message reached the SimpleX
// relay, and when a sent one was sent.
ItemTs time.Time `json:"itemTs"`
} `json:"meta"`
Content struct {
Type string `json:"type"`
MsgContent *MsgContent `json:"msgContent,omitempty"`
} `json:"content"`
}
// MsgContent is a message body: its type, such as "text", "image"
// or "file", and its text, which for anything but "text" is the
// caption. Only "text" is sent here.
MsgContent struct {
Type string `json:"type"`
Text string `json:"text"`
@@ -142,6 +163,13 @@ type (
}
)
// Deleted reports whether the contact is gone, as it is once the person
// deletes their chat with the bot. The chat client still lists such a
// contact, with its chat, but nothing more reaches them.
func (c Contact) Deleted() bool {
return c.Status != "active"
}
// Message is a text message a contact sent to the bot.
type Message struct {
ContactID int64
@@ -190,6 +218,24 @@ func cmdSetAddressSettings(userID int64, s AddressSettings) (string, error) {
return "/_address_settings " + strconv.FormatInt(userID, 10) + " " + string(b), nil
}
func cmdListContacts(userID int64) string {
return "/_contacts " + strconv.FormatInt(userID, 10)
}
// cmdGetChat asks for the last count items of the chat with a contact,
// or, unless beforeItemID is 0, the last count before that item. It is
// missing from COMMANDS.md; its syntax is the client's parser's, in
// src/Simplex/Chat/Library/Commands.hs.
func cmdGetChat(contactID, beforeItemID int64, count int) string {
cmd := "/_get chat @" + strconv.FormatInt(contactID, 10)
if beforeItemID != 0 {
cmd += " before=" + strconv.FormatInt(beforeItemID, 10)
}
return cmd + " count=" + strconv.Itoa(count)
}
func cmdSendText(contactID, quotedItemID int64, text string) (string, error) {
b, err := json.Marshal([]composedMessage{{
QuotedItemID: quotedItemID,