# 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 bot keeps what it must not lose: the SimpleX
database, which holds the bot's profile, its keys, its address and its
contacts, and `webhooks.json`, which holds the webhooks registered
through the [API](#api). Default `./data`; the image sets
`/var/lib/simplexcalc`.
- `DEBUG` — `true` or `false`, default `false`. `true` logs every event
the chat client sends.
- `PORT` — the TCP port the API listens on, on all interfaces: a whole
number from 1 to 65535, default `8080`.
- `API_TOKEN_FILE` — path of a file holding the API credential, at least
32 characters not counting the whitespace around them. The file is
read once, at startup; one that cannot be read, or holds a shorter
credential, aborts startup. Absent, the API still listens but refuses
every request, and startup logs a warning saying so.
## API
An HTTP API beside the chat client lets another program read the bot's
chats, send messages in them and register webhooks on them. It speaks
JSON on `PORT`.
**Authentication.** Every request carries the credential from
`API_TOKEN_FILE`:
```
Authorization: Bearer {credential}
```
A request without it, or with a wrong one, gets `401` with
`WWW-Authenticate: Bearer` and `{"error":"unauthorized"}`. No path is
exempt. Without `API_TOKEN_FILE`, every request gets that answer. The
file is read at startup, so a new credential takes a restart.
**Errors** are JSON with a short explanation, such as
`{"error":"not found"}`; what went wrong inside goes to the bot's log.
### `GET /api/v1/chats`
The bot's chats, ordered by `id`. The bot talks to people only one to
one, so each chat is one of its contacts.
```sh
TOKEN=$(docker exec simplexcalc cat /var/lib/simplexcalc/api-token)
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/api/v1/chats
```
```json
{
"chats": [
{ "id": 3, "display_name": "tester", "contact_deleted": false },
{ "id": 4, "display_name": "tester", "contact_deleted": true }
]
}
```
- `id` — the chat's number, which is its contact's number in the chat
client.
- `display_name` — the name the contact gave themselves. Nothing makes
it unique.
- `contact_deleted` — `true` once the contact has deleted their chat
with the bot. The chat stays in the list, but nothing more reaches
them.
### `GET /api/v1/chats/{id}/messages`
The latest messages in the chat `id`, oldest first. `count`, a whole
number from 1 to 100 and 20 if absent, is how many of the chat's latest
items to read. The chat client also records events in a chat, such as
the contact connecting, and only messages are returned, so fewer than
`count` can come back.
```sh
curl -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:8080/api/v1/chats/3/messages?count=5'
```
```json
{
"messages": [
{
"id": 9,
"direction": "received",
"type": "text",
"text": "2 + 2",
"time": "2026-09-29T03:14:34Z"
},
{
"id": 10,
"direction": "sent",
"type": "text",
"text": "4",
"time": "2026-09-29T03:14:35.101223457Z"
}
]
}
```
A message has:
- `id` — its number, unique across all the bot's chats.
- `direction` — `received` from the contact, or `sent` by the bot.
- `type` — `text`, or another kind of SimpleX message, such as `image`,
`file` or `voice`.
- `text` — the text; for a message that is not `text`, its caption,
which can be empty.
- `time` — in RFC 3339, in UTC: for a received message, when it reached
the SimpleX relay, to the second; for a sent one, when the bot sent
it.
The answer is `400` if `count` is not a whole number from 1 to 100, and
`404` if `GET /api/v1/chats` does not list `id`. A query the server
cannot read gets `400` with `the query cannot be read`; examples are a
query that holds a `;`, a `%` not followed by two hexadecimal digits, or
more than 10,000 parts separated by `&`.
### `POST /api/v1/chats/{id}/messages`
Sends the `text` in the body to the chat `id`, and answers `201` with
the message as sent. The answer comes once the chat client has taken the
message, before it reaches the contact.
```sh
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"hello"}' http://127.0.0.1:8080/api/v1/chats/3/messages
```
```json
{
"message": {
"id": 12,
"direction": "sent",
"type": "text",
"text": "hello",
"time": "2026-09-29T03:14:43.519552587Z"
}
}
```
Nothing is sent, and the answer is:
- `400` if the body is not JSON of that shape, or `text` is empty;
- `404` if `GET /api/v1/chats` does not list `id`;
- `409` if the contact cannot receive messages: they have deleted their
chat with the bot (`contact_deleted` is `true`), or have not finished
connecting;
- `413` if the body is over 64 KiB, or the text is too long for one
SimpleX message, which holds about 15,000 bytes.
### `POST /api/v1/chats/{id}/webhooks`
Registers the `url` in the body as a webhook on the chat `id`, and
answers `201` with the webhook. If the chat already has a webhook with
the same `url`, character for character, the answer is `200` with that
webhook instead. Each new message the contact sends in the chat is then
posted to the webhook, as
[Messages posted to webhooks](#messages-posted-to-webhooks) describes.
```sh
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"url":"https://example.com/hook"}' \
http://127.0.0.1:8080/api/v1/chats/3/webhooks
```
```json
{
"id": "5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80",
"chat_id": 3,
"url": "https://example.com/hook"
}
```
A webhook has:
- `id` — 16 random bytes, written as 32 hexadecimal digits.
- `chat_id` — the chat it is registered on.
- `url` — the URL, as it was registered.
The bot keeps the webhooks in `webhooks.json` in `DATA_DIR`, with mode
0600, and reads that file at startup, so they survive a restart. Without
the file there are none; a file that cannot be read, or that holds
anything but webhooks as the bot writes them, aborts startup, as
configuration that cannot be parsed does.
Nothing is registered, and the answer is:
- `400` if the body is not JSON of that shape, or `url` is not an
absolute `http` or `https` URL with a host, at most 2048 bytes long;
- `404` if `GET /api/v1/chats` does not list `id`;
- `413` if the body is over 64 KiB;
- `500` if `webhooks.json` cannot be written.
### `GET /api/v1/chats/{id}/webhooks`
The webhooks registered on the chat `id`, in the order they were
registered; `404` if `GET /api/v1/chats` does not list `id`. A chat's
webhooks stay after its contact deletes the chat (`contact_deleted` is
`true`), until they are removed.
```sh
curl -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8080/api/v1/chats/3/webhooks
```
```json
{
"webhooks": [
{
"id": "5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80",
"chat_id": 3,
"url": "https://example.com/hook"
}
]
}
```
### `DELETE /api/v1/chats/{id}/webhooks/{webhook_id}`
Removes the webhook `webhook_id` from the chat `id`, and answers `204`
with no body.
```sh
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8080/api/v1/chats/3/webhooks/5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80
```
Nothing is removed, and the answer is:
- `404` if `GET /api/v1/chats` does not list `id`, or the chat has no
webhook `webhook_id`;
- `500` if `webhooks.json` cannot be written.
### Messages posted to webhooks
Each new message a contact sends in a chat is posted to each of the
chat's webhooks: a `POST` to its `url`, with
`Content-Type: application/json`, whose body is the chat's `id` as
`chat_id` and the message as `GET /api/v1/chats/{id}/messages` shows it:
```json
{
"chat_id": 3,
"message": {
"id": 9,
"direction": "received",
"type": "text",
"text": "2 + 2",
"time": "2026-09-29T03:14:34Z"
}
}
```
Every kind of message is posted, not only arithmetic, so `type` can be
`image`, `file` or another kind, with the caption as `text`. Messages
the bot sends, its replies and those sent through the API, are not
posted.
A message is posted to each webhook at most once: there are no retries.
A webhook misses it if the bot cannot reach it, if it does not answer
within 10 seconds, or if it answers with a status other than 2xx; a
redirect is not followed, and counts as such a status. So that no
webhook holds up the bot's replies, messages wait in a queue, and the
bot posts four at a time; a message that arrives while 100 wait is not
posted. Posting goes on while the bot stops; once its chat client has
exited, posts in progress are abandoned, and messages still waiting are
not posted.
The bot logs two warnings, which never hold a webhook's URL, as it can
carry a secret, nor a message's text:
- `dropping a message for the webhooks: the queue is full`, with
`chat_id` and `message_id`, for a message that found the queue full;
- `posting a message to a webhook`, with `webhook_id`, `message_id` and
either the `status` the webhook answered with or the `error` that
ended the post, for each post that got no 2xx answer, abandoned posts
included. The error can name the webhook's host.
## Entrypoints
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.
- **Webhooks** (`internal/api`) are read from `$DATA_DIR/webhooks.json`
before the chat client starts. Changes are made one at a time, and
each rewrites the file whole: the bot writes a temporary file beside
it, named `webhooks.json.` and digits, and renames it over the old
one. A crash leaves the old file or the new one, never part of one,
and at worst a stray temporary file, which the bot ignores. Messages
are posted to the webhooks by four goroutines serving a queue. The
goroutine that delivers the chat client's events, and also its
answers, only puts each message in the queue, never waiting: not for a
webhook, and not for the list of webhooks, which waits while the file
is written. Posting goes on while the bot stops, until the chat client
has exited.
- **Replies**: for each text message a contact sends in a direct chat,
the bot sends back the result, as a reply quoting the message. Group
messages, files and the bot's own messages are ignored.
- **Arithmetic** (`internal/calc`): a small parser of its own reads
numbers, `+ - * / % ^`, signs and parentheses, and refuses anything
else. `go/constant` computes with exact rationals. `^`, also written
`**`, is a power: it binds tighter than `*`, `/`, `%` and a sign on
its left, and groups to the right, so `2^3^2` is `512`, `-2^2` is
`-4`, `(-2)^2` is `4` and `2^-1` is `0.5`. `%` is the remainder and
ranks with `*` and `/`; its result takes the sign of the divisor, as
in Python, so `7 % 3` is `1`, `-7 % 3` is `2` and `7.5 % 2` is `1.5`.
A power with a whole exponent is exact, so `0.1^2` is `0.01` and
`2^-1400 * 2^1400` is `1`, unless `go/constant` could hold the result
only rounded; that power, and one with a fractional exponent, is
computed as a double, so `2^0.5` is `1.4142135623730951`. A negative
number to a fractional power is refused, as having no real result.
Numbers are read as decimal, so `010` is ten. Input over 256 bytes is
refused, exact powers are capped, and every number is held as a
fraction, whole numbers too, under the 4096-bit limit below, so a
message cannot make the bot do unbounded work. Whole numbers below
1021 are written exactly; other results in the shortest
form that reads back as the same double, in exponent notation from
1021 up and below 10-6. 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`; 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
`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)