check / check (push) Successful in 1m21s
GET /api/v1/chats/{id}/messages returns the messages among a chat's
last count items (default 20, at most 100), oldest first. POST sends a
text, waits for the chat client's answer and returns 201 with the
message as sent. A chat the bot does not have is 404, a contact who
deleted the chat is 409, a text too long for one message is 413, and
any other failure is 500 with a chosen sentence.
The chat client spells out a message's formatting in its answer, up to
26 times the text's length, so 100 items in one answer can pass the
16 MiB read limit and end the connection. Items are read five at a
time.
Model: opus-5-5
378 lines
16 KiB
Markdown
378 lines
16 KiB
Markdown
# simplexcalc
|
|
|
|
simplexcalc is a Go chat bot by [@sneak](https://sneak.berlin) for the
|
|
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, `+ - * /`, 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, 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: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 \
|
|
-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
|
|
app, or paste it into its "connect via link" screen; the bot accepts at
|
|
once, greets you, and answers every message you send it.
|
|
|
|
The address is stored on the `simplexcalc-data` volume. It survives
|
|
restarts, image upgrades and recreating the container, and is lost only
|
|
with the volume.
|
|
|
|
### Messaging it from a terminal
|
|
|
|
The image carries the SimpleX Chat command-line client, so a throwaway
|
|
second client can talk to the bot without installing anything. Replace
|
|
`ADDRESS` with the bot's address:
|
|
|
|
```sh
|
|
docker run --rm -v simplexcalc-tester:/var/lib/simplexcalc simplexcalc \
|
|
simplex-chat -d /var/lib/simplexcalc/tester --user-display-name tester \
|
|
-e "/c ADDRESS" -t 30 --execute-log all
|
|
docker run --rm -v simplexcalc-tester:/var/lib/simplexcalc simplexcalc \
|
|
simplex-chat -d /var/lib/simplexcalc/tester \
|
|
-e "@calc 5 * 5/2" -t 20 --execute-log messages
|
|
docker volume rm simplexcalc-tester
|
|
```
|
|
|
|
The first command connects and prints the bot's greeting; the second
|
|
sends `5 * 5/2` and prints the reply, `12.5`.
|
|
|
|
## Configuration
|
|
|
|
All configuration is environment variables, read once at startup. A
|
|
`.env` file in the working directory is loaded automatically for
|
|
development.
|
|
|
|
**A variable that is set to something unparseable aborts startup.** It
|
|
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
|
|
`/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 and send messages in 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.
|
|
|
|
A `count` other than a whole number from 1 to 100 gets `400`, and an
|
|
`id` that is not one of the bot's chats gets `404`.
|
|
|
|
### `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 `id` is not one of the bot's chats;
|
|
- `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.
|
|
|
|
## Entrypoints
|
|
|
|
This repo adheres to the
|
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
|
standard. The `script/` entrypoints, each with a thin `make` shim:
|
|
|
|
- `script/bootstrap` (`make bootstrap`) — install build dependencies
|
|
(git, make, go) idempotently. Linting additionally needs docker;
|
|
markdown formatting needs docker or a local `prettier`.
|
|
- `script/setup` (`make setup`) — `bootstrap` plus the git pre-commit
|
|
hook
|
|
- `script/test` (`make test`) — `go test` with the race detector and
|
|
coverage, `-count=1`, 90 s timeout; quiet on success, verbose rerun on
|
|
failure
|
|
- `script/lint` (`make lint`) — `golangci-lint` via docker only, against
|
|
the digest-pinned image, then `script/assert-step-ran` and
|
|
`script/assert-context-complete` over the build log. Nothing is
|
|
installed locally and nothing runs on the host.
|
|
- `script/fmt` (`make fmt`) — format Go with `gofmt` and everything else
|
|
with `prettier` (writes)
|
|
- `script/fmt-check` (`make fmt-check`) — the same scope, read-only
|
|
- `script/check` (`make check`) — `test` + `lint` + `fmt-check`. What
|
|
the pre-commit hook runs. Never modifies files.
|
|
- `script/docker` (`make docker`) — build the image, tagged with
|
|
`script/projectname`
|
|
- `script/cibuild` (`make cibuild`) — the CI gate:
|
|
`docker build --progress=plain --no-cache-filter=lint --no-cache-filter=builder .`
|
|
followed by assertions that the lint step and the test step really
|
|
ran, and that both stages really received the whole repository. The
|
|
Gitea workflow runs this on every push.
|
|
- `script/precommit` — the hook body: `go mod tidy` must not change
|
|
`go.mod`/`go.sum`, then `check`
|
|
- `script/install-precommit` (`make hooks`) — installs the hook
|
|
- `script/projectname` — prints the project name; other scripts call it
|
|
so they can stay identical across repos
|
|
- `script/prettier`, `script/assert-step-ran`,
|
|
`script/assert-context-complete`, `script/repo-source-manifest` —
|
|
helpers, not entrypoints
|
|
|
|
`make build`, `make run`, `make dev`, `make deps` and `make clean` are
|
|
the ordinary conveniences. `make run` and `make dev` run the bot on the
|
|
host, which needs the `simplex-chat` command-line client on `PATH`.
|
|
|
|
### Why the assertions exist
|
|
|
|
A green `docker build` is not evidence that the checks ran. On an
|
|
unchanged tree every layer comes from cache and the build exits 0 having
|
|
executed nothing; BuildKit silently ignores a `--no-cache-filter` naming
|
|
a stage that no longer exists; and a `.dockerignore` entry can remove a
|
|
package from the build context, after which the linter genuinely runs,
|
|
genuinely examines what it was handed, and genuinely reports `0 issues.`
|
|
over a repository with a violation in it.
|
|
|
|
So the build is not trusted. `script/assert-step-ran` requires the log
|
|
to show the step executing and the tool's own success line coming out of
|
|
it. `script/assert-context-complete` requires the file inventory each
|
|
stage emitted to match the git index — an expectation `.dockerignore`
|
|
cannot reach. Read the comments in those two scripts before changing
|
|
either; they document what they still cannot see.
|
|
|
|
## Rationale
|
|
|
|
SimpleX Chat is an end-to-end encrypted messenger with no user
|
|
identifiers: people connect through addresses they choose to share.
|
|
simplexcalc is a calculator anyone can reach that way, and a small,
|
|
complete example of a SimpleX bot in Go: profile and address set-up,
|
|
automatic acceptance, and replies, with the chat client in the same
|
|
container.
|
|
|
|
## Design
|
|
|
|
- **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`. 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
|
|
are decoded, so records that grow new fields in a later client release
|
|
still decode.
|
|
- **Set-up on every start** (`internal/bot`): read the bot's profile,
|
|
create its long-term address if it has none, and set the address to
|
|
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.
|
|
- **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`): 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>. 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 (`1e-1300 + 1`); a power computed as a double whose
|
|
base or result is outside the normal range of a double, about 2.2e-308
|
|
to 1.8e308 in magnitude, where a double keeps all its digits
|
|
(`1e-400^0.5`); and a result other than zero outside that range, as it
|
|
is written through a double (`1e400`, `2^-1400`).
|
|
- **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
|
|
chat client with `SIGTERM` and kills it if it has not exited within 10
|
|
seconds.
|
|
- **The image**: a Go build stage, then an Ubuntu 22.04 runtime — the
|
|
release the chat client is built for, which already has every library
|
|
it links — with the `simplex-chat` v7.0.2 binary downloaded by an
|
|
`ADD` whose checksum BuildKit verifies. It runs as an unprivileged
|
|
user and is x86_64 only.
|
|
|
|
## Operating it
|
|
|
|
**Backup.** Everything durable is on the volume: the SimpleX database,
|
|
`simplex_chat.db` and `simplex_agent.db`, and the API credential,
|
|
`api-token`. 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, and the credential lets its holder use the API; keep both 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
|
|
`simplex-chat` release is a change to the URL and the checksum on the
|
|
`ADD` line in the `Dockerfile`.
|
|
|
|
## TODO
|
|
|
|
See `docs/TODO.md` and the repository's issue tracker.
|
|
|
|
## Author
|
|
|
|
[@sneak](https://sneak.berlin)
|