check / check (push) Successful in 1m19s
The chat client keeps contact records that GET /api/v1/chats does not list, such as the bot's own profile (1 on a new profile) and a contact it creates itself (2), and reads or sends in them when asked. The message endpoints now look the id up, through chatID in internal/api/chats.go, in the same list the chats endpoint answers with, and answer 404 for any id not in it. The webhook endpoints can call chatID too. 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 `GET /api/v1/chats` does not list 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 `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.
|
|
|
|
## 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)
|