next: exponentiation, modulo, chat API and webhooks #10
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Agent Guidance
|
title: Agent Guidance
|
||||||
last_modified: 2026-09-26
|
last_modified: 2026-09-29
|
||||||
---
|
---
|
||||||
|
|
||||||
This file is the single source of guidance for any automated agent
|
This file is the single source of guidance for any automated agent
|
||||||
@@ -118,9 +118,11 @@ Do not weaken them.
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/simplexcalc/ cobra command tree; main(), run and version
|
cmd/simplexcalc/ main(), a single call into internal/cli
|
||||||
|
internal/api/ the HTTP API: its credential, headers and endpoints
|
||||||
internal/bot/ startup, address setup, and the reply to a message
|
internal/bot/ startup, address setup, and the reply to a message
|
||||||
internal/calc/ the arithmetic: go/parser and go/constant
|
internal/calc/ the arithmetic: its own parser, and go/constant
|
||||||
|
internal/cli/ cobra command tree: run and version
|
||||||
internal/config/ viper-backed configuration; the abort-on-garbage rule
|
internal/config/ viper-backed configuration; the abort-on-garbage rule
|
||||||
internal/logger/ log/slog, JSON always
|
internal/logger/ log/slog, JSON always
|
||||||
internal/simplex/ the simplex-chat child process and its WebSocket API
|
internal/simplex/ the simplex-chat child process and its WebSocket API
|
||||||
|
|||||||
+7
-3
@@ -109,9 +109,9 @@ WORKDIR /app
|
|||||||
COPY --from=builder /build/bin/simplexcalc /app/simplexcalc
|
COPY --from=builder /build/bin/simplexcalc /app/simplexcalc
|
||||||
|
|
||||||
# Data directory: the SimpleX database, which holds the bot's profile,
|
# Data directory: the SimpleX database, which holds the bot's profile,
|
||||||
# its keys, its address and its contacts. Mount a volume over it;
|
# its keys, its address and its contacts, and the API's webhooks. Mount
|
||||||
# without one, the bot gets a new address every time the container is
|
# a volume over it; without one, the bot gets a new address and loses
|
||||||
# recreated.
|
# its webhooks every time the container is recreated.
|
||||||
RUN mkdir -p /var/lib/simplexcalc && \
|
RUN mkdir -p /var/lib/simplexcalc && \
|
||||||
chown simplexcalc:simplexcalc /var/lib/simplexcalc
|
chown simplexcalc:simplexcalc /var/lib/simplexcalc
|
||||||
|
|
||||||
@@ -119,4 +119,8 @@ USER simplexcalc
|
|||||||
|
|
||||||
ENV DATA_DIR=/var/lib/simplexcalc
|
ENV DATA_DIR=/var/lib/simplexcalc
|
||||||
|
|
||||||
|
# The API, on its default PORT. The chat client's WebSocket on 5225 is
|
||||||
|
# not exposed: it has no authentication.
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["/app/simplexcalc", "run"]
|
CMD ["/app/simplexcalc", "run"]
|
||||||
|
|||||||
@@ -5,24 +5,35 @@ SimpleX Chat network: it accepts every contact request and answers
|
|||||||
arithmetic such as `2 + 2` with the result.
|
arithmetic such as `2 + 2` with the result.
|
||||||
|
|
||||||
Send it `2 + 2` and it replies `4`; send `5 * 5/2` and it replies
|
Send it `2 + 2` and it replies `4`; send `5 * 5/2` and it replies
|
||||||
`12.5`. It understands decimal numbers, `+ - * /`, unary minus and
|
`12.5`. It understands decimal numbers, `+ - * /`, powers written `2^10`
|
||||||
parentheses, and computes exactly, so `0.1 + 0.2` is `0.3`. Anything
|
or `2**10`, remainders written `7 % 3`, signs and parentheses, and
|
||||||
else gets a short explanation instead of a result.
|
computes exactly, so `0.1 + 0.2` is `0.3`. Anything else gets a short
|
||||||
|
explanation instead of a result.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Build the image and run the bot, with its SimpleX profile on a named
|
Build the image, write a random API credential onto a named volume, and
|
||||||
volume:
|
run the bot with its SimpleX profile on the same volume:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone git@git.eeqj.de:clawbot/simplexcalc.git
|
git clone git@git.eeqj.de:sneak/simplexcalc.git
|
||||||
cd simplexcalc
|
cd simplexcalc
|
||||||
make docker
|
make docker
|
||||||
|
docker run --rm -v simplexcalc-data:/var/lib/simplexcalc simplexcalc \
|
||||||
|
sh -c 'umask 077 && od -An -N32 -tx1 /dev/urandom | tr -d " \n" \
|
||||||
|
>/var/lib/simplexcalc/api-token'
|
||||||
docker run -d --name simplexcalc --restart unless-stopped \
|
docker run -d --name simplexcalc --restart unless-stopped \
|
||||||
-v simplexcalc-data:/var/lib/simplexcalc simplexcalc
|
-v simplexcalc-data:/var/lib/simplexcalc \
|
||||||
|
-p 127.0.0.1:8080:8080 -e API_TOKEN_FILE=/var/lib/simplexcalc/api-token \
|
||||||
|
simplexcalc
|
||||||
docker logs simplexcalc 2>&1 | grep '"msg":"ready"'
|
docker logs simplexcalc 2>&1 | grep '"msg":"ready"'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The credential is 32 random bytes written as 64 hexadecimal characters
|
||||||
|
to `/var/lib/simplexcalc/api-token`, readable only by the bot's user.
|
||||||
|
`-p 127.0.0.1:8080:8080` makes the API reachable from this host only;
|
||||||
|
see [API](#api).
|
||||||
|
|
||||||
The `ready` log line carries the bot's contact address: `address` is the
|
The `ready` log line carries the bot's contact address: `address` is the
|
||||||
short link to share, and `full_address` is the same address in the long
|
short link to share, and `full_address` is the same address in the long
|
||||||
form that older SimpleX clients need. Open the link in any SimpleX Chat
|
form that older SimpleX clients need. Open the link in any SimpleX Chat
|
||||||
@@ -62,11 +73,281 @@ development.
|
|||||||
is never quietly replaced by the default. Defaults apply only to
|
is never quietly replaced by the default. Defaults apply only to
|
||||||
variables that are absent.
|
variables that are absent.
|
||||||
|
|
||||||
- `DATA_DIR` — where the SimpleX database lives: the bot's profile, its
|
- `DATA_DIR` — where the bot keeps what it must not lose: the SimpleX
|
||||||
keys, its address and its contacts. Default `./data`; the image sets
|
database, which holds the bot's profile, its keys, its address and its
|
||||||
|
contacts, and `webhooks.json`, which holds the webhooks registered
|
||||||
|
through the [API](#api). Default `./data`; the image sets
|
||||||
`/var/lib/simplexcalc`.
|
`/var/lib/simplexcalc`.
|
||||||
- `DEBUG` — `true` or `false`, default `false`. `true` logs every event
|
- `DEBUG` — `true` or `false`, default `false`. `true` logs every event
|
||||||
the chat client sends.
|
the chat client sends.
|
||||||
|
- `PORT` — the TCP port the API listens on, on all interfaces: a whole
|
||||||
|
number from 1 to 65535, default `8080`.
|
||||||
|
- `API_TOKEN_FILE` — path of a file holding the API credential, at least
|
||||||
|
32 characters not counting the whitespace around them. The file is
|
||||||
|
read once, at startup; one that cannot be read, or holds a shorter
|
||||||
|
credential, aborts startup. Absent, the API still listens but refuses
|
||||||
|
every request, and startup logs a warning saying so.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
An HTTP API beside the chat client lets another program read the bot's
|
||||||
|
chats, send messages in them and register webhooks on them. It speaks
|
||||||
|
JSON on `PORT`.
|
||||||
|
|
||||||
|
**Authentication.** Every request carries the credential from
|
||||||
|
`API_TOKEN_FILE`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer {credential}
|
||||||
|
```
|
||||||
|
|
||||||
|
A request without it, or with a wrong one, gets `401` with
|
||||||
|
`WWW-Authenticate: Bearer` and `{"error":"unauthorized"}`. No path is
|
||||||
|
exempt. Without `API_TOKEN_FILE`, every request gets that answer. The
|
||||||
|
file is read at startup, so a new credential takes a restart.
|
||||||
|
|
||||||
|
**Errors** are JSON with a short explanation, such as
|
||||||
|
`{"error":"not found"}`; what went wrong inside goes to the bot's log.
|
||||||
|
|
||||||
|
### `GET /api/v1/chats`
|
||||||
|
|
||||||
|
The bot's chats, ordered by `id`. The bot talks to people only one to
|
||||||
|
one, so each chat is one of its contacts.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TOKEN=$(docker exec simplexcalc cat /var/lib/simplexcalc/api-token)
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/api/v1/chats
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chats": [
|
||||||
|
{ "id": 3, "display_name": "tester", "contact_deleted": false },
|
||||||
|
{ "id": 4, "display_name": "tester", "contact_deleted": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `id` — the chat's number, which is its contact's number in the chat
|
||||||
|
client.
|
||||||
|
- `display_name` — the name the contact gave themselves. Nothing makes
|
||||||
|
it unique.
|
||||||
|
- `contact_deleted` — `true` once the contact has deleted their chat
|
||||||
|
with the bot. The chat stays in the list, but nothing more reaches
|
||||||
|
them.
|
||||||
|
|
||||||
|
### `GET /api/v1/chats/{id}/messages`
|
||||||
|
|
||||||
|
The latest messages in the chat `id`, oldest first. `count`, a whole
|
||||||
|
number from 1 to 100 and 20 if absent, is how many of the chat's latest
|
||||||
|
items to read. The chat client also records events in a chat, such as
|
||||||
|
the contact connecting, and only messages are returned, so fewer than
|
||||||
|
`count` can come back.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
'http://127.0.0.1:8080/api/v1/chats/3/messages?count=5'
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"direction": "received",
|
||||||
|
"type": "text",
|
||||||
|
"text": "2 + 2",
|
||||||
|
"time": "2026-09-29T03:14:34Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"direction": "sent",
|
||||||
|
"type": "text",
|
||||||
|
"text": "4",
|
||||||
|
"time": "2026-09-29T03:14:35.101223457Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A message has:
|
||||||
|
|
||||||
|
- `id` — its number, unique across all the bot's chats.
|
||||||
|
- `direction` — `received` from the contact, or `sent` by the bot.
|
||||||
|
- `type` — `text`, or another kind of SimpleX message, such as `image`,
|
||||||
|
`file` or `voice`.
|
||||||
|
- `text` — the text; for a message that is not `text`, its caption,
|
||||||
|
which can be empty.
|
||||||
|
- `time` — in RFC 3339, in UTC: for a received message, when it reached
|
||||||
|
the SimpleX relay, to the second; for a sent one, when the bot sent
|
||||||
|
it.
|
||||||
|
|
||||||
|
The answer is `400` if `count` is not a whole number from 1 to 100, and
|
||||||
|
`404` if `GET /api/v1/chats` does not list `id`. A query the server
|
||||||
|
cannot read gets `400` with `the query cannot be read`; examples are a
|
||||||
|
query that holds a `;`, a `%` not followed by two hexadecimal digits, or
|
||||||
|
more than 10,000 parts separated by `&`.
|
||||||
|
|
||||||
|
### `POST /api/v1/chats/{id}/messages`
|
||||||
|
|
||||||
|
Sends the `text` in the body to the chat `id`, and answers `201` with
|
||||||
|
the message as sent. The answer comes once the chat client has taken the
|
||||||
|
message, before it reaches the contact.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"text":"hello"}' http://127.0.0.1:8080/api/v1/chats/3/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"id": 12,
|
||||||
|
"direction": "sent",
|
||||||
|
"type": "text",
|
||||||
|
"text": "hello",
|
||||||
|
"time": "2026-09-29T03:14:43.519552587Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing is sent, and the answer is:
|
||||||
|
|
||||||
|
- `400` if the body is not JSON of that shape, or `text` is empty;
|
||||||
|
- `404` if `GET /api/v1/chats` does not list `id`;
|
||||||
|
- `409` if the contact cannot receive messages: they have deleted their
|
||||||
|
chat with the bot (`contact_deleted` is `true`), or have not finished
|
||||||
|
connecting;
|
||||||
|
- `413` if the body is over 64 KiB, or the text is too long for one
|
||||||
|
SimpleX message, which holds about 15,000 bytes.
|
||||||
|
|
||||||
|
### `POST /api/v1/chats/{id}/webhooks`
|
||||||
|
|
||||||
|
Registers the `url` in the body as a webhook on the chat `id`, and
|
||||||
|
answers `201` with the webhook. If the chat already has a webhook with
|
||||||
|
the same `url`, character for character, the answer is `200` with that
|
||||||
|
webhook instead. Each new message the contact sends in the chat is then
|
||||||
|
posted to the webhook, as
|
||||||
|
[Messages posted to webhooks](#messages-posted-to-webhooks) describes.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"url":"https://example.com/hook"}' \
|
||||||
|
http://127.0.0.1:8080/api/v1/chats/3/webhooks
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80",
|
||||||
|
"chat_id": 3,
|
||||||
|
"url": "https://example.com/hook"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A webhook has:
|
||||||
|
|
||||||
|
- `id` — 16 random bytes, written as 32 hexadecimal digits.
|
||||||
|
- `chat_id` — the chat it is registered on.
|
||||||
|
- `url` — the URL, as it was registered.
|
||||||
|
|
||||||
|
The bot keeps the webhooks in `webhooks.json` in `DATA_DIR`, with mode
|
||||||
|
0600, and reads that file at startup, so they survive a restart. Without
|
||||||
|
the file there are none; a file that cannot be read, or that holds
|
||||||
|
anything but webhooks as the bot writes them, aborts startup, as
|
||||||
|
configuration that cannot be parsed does.
|
||||||
|
|
||||||
|
Nothing is registered, and the answer is:
|
||||||
|
|
||||||
|
- `400` if the body is not JSON of that shape, or `url` is not an
|
||||||
|
absolute `http` or `https` URL with a host, at most 2048 bytes long;
|
||||||
|
- `404` if `GET /api/v1/chats` does not list `id`;
|
||||||
|
- `413` if the body is over 64 KiB;
|
||||||
|
- `500` if `webhooks.json` cannot be written.
|
||||||
|
|
||||||
|
### `GET /api/v1/chats/{id}/webhooks`
|
||||||
|
|
||||||
|
The webhooks registered on the chat `id`, in the order they were
|
||||||
|
registered; `404` if `GET /api/v1/chats` does not list `id`. A chat's
|
||||||
|
webhooks stay after its contact deletes the chat (`contact_deleted` is
|
||||||
|
`true`), until they are removed.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
http://127.0.0.1:8080/api/v1/chats/3/webhooks
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"webhooks": [
|
||||||
|
{
|
||||||
|
"id": "5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80",
|
||||||
|
"chat_id": 3,
|
||||||
|
"url": "https://example.com/hook"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `DELETE /api/v1/chats/{id}/webhooks/{webhook_id}`
|
||||||
|
|
||||||
|
Removes the webhook `webhook_id` from the chat `id`, and answers `204`
|
||||||
|
with no body.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
|
||||||
|
http://127.0.0.1:8080/api/v1/chats/3/webhooks/5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing is removed, and the answer is:
|
||||||
|
|
||||||
|
- `404` if `GET /api/v1/chats` does not list `id`, or the chat has no
|
||||||
|
webhook `webhook_id`;
|
||||||
|
- `500` if `webhooks.json` cannot be written.
|
||||||
|
|
||||||
|
### Messages posted to webhooks
|
||||||
|
|
||||||
|
Each new message a contact sends in a chat is posted to each of the
|
||||||
|
chat's webhooks: a `POST` to its `url`, with
|
||||||
|
`Content-Type: application/json`, whose body is the chat's `id` as
|
||||||
|
`chat_id` and the message as `GET /api/v1/chats/{id}/messages` shows it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chat_id": 3,
|
||||||
|
"message": {
|
||||||
|
"id": 9,
|
||||||
|
"direction": "received",
|
||||||
|
"type": "text",
|
||||||
|
"text": "2 + 2",
|
||||||
|
"time": "2026-09-29T03:14:34Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every kind of message is posted, not only arithmetic, so `type` can be
|
||||||
|
`image`, `file` or another kind, with the caption as `text`. Messages
|
||||||
|
the bot sends, its replies and those sent through the API, are not
|
||||||
|
posted.
|
||||||
|
|
||||||
|
A message is posted to each webhook at most once: there are no retries.
|
||||||
|
A webhook misses it if the bot cannot reach it, if it does not answer
|
||||||
|
within 10 seconds, or if it answers with a status other than 2xx; a
|
||||||
|
redirect is not followed, and counts as such a status. So that no
|
||||||
|
webhook holds up the bot's replies, messages wait in a queue, and the
|
||||||
|
bot posts four at a time; a message that arrives while 100 wait is not
|
||||||
|
posted. Posting goes on while the bot stops; once its chat client has
|
||||||
|
exited, posts in progress are abandoned, and messages still waiting are
|
||||||
|
not posted.
|
||||||
|
|
||||||
|
The bot logs two warnings, which never hold a webhook's URL, as it can
|
||||||
|
carry a secret, nor a message's text:
|
||||||
|
|
||||||
|
- `dropping a message for the webhooks: the queue is full`, with
|
||||||
|
`chat_id` and `message_id`, for a message that found the queue full;
|
||||||
|
- `posting a message to a webhook`, with `webhook_id`, `message_id` and
|
||||||
|
either the `status` the webhook answered with or the `error` that
|
||||||
|
ended the post, for each post that got no 2xx answer, abandoned posts
|
||||||
|
included. The error can name the webhook's host.
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|
||||||
@@ -142,8 +423,8 @@ container.
|
|||||||
- **One process tree, one container.** `simplexcalc run` starts the
|
- **One process tree, one container.** `simplexcalc run` starts the
|
||||||
SimpleX Chat command-line client, `simplex-chat`, as a child process,
|
SimpleX Chat command-line client, `simplex-chat`, as a child process,
|
||||||
with its database under `$DATA_DIR/simplex` and its WebSocket API on
|
with its database under `$DATA_DIR/simplex` and its WebSocket API on
|
||||||
`127.0.0.1:5225`. The API has no authentication, which is why it is
|
`127.0.0.1:5225`. That WebSocket has no authentication, which is why
|
||||||
never exposed outside the container.
|
it is never exposed outside the container.
|
||||||
- **The protocol** (`internal/simplex`) is JSON over that WebSocket: a
|
- **The protocol** (`internal/simplex`) is JSON over that WebSocket: a
|
||||||
command carries a correlation id, its response carries the same id,
|
command carries a correlation id, its response carries the same id,
|
||||||
and anything without one is an event. Only the fields the bot reads
|
and anything without one is an event. Only the fields the bot reads
|
||||||
@@ -154,19 +435,66 @@ container.
|
|||||||
accept every contact request and to greet each new contact. The first
|
accept every contact request and to greet each new contact. The first
|
||||||
start creates the profile itself, a bot profile named `calc`. The
|
start creates the profile itself, a bot profile named `calc`. The
|
||||||
address is logged in the `ready` line.
|
address is logged in the `ready` line.
|
||||||
|
- **The API** (`internal/api`): an HTTP server, routed with chi, that
|
||||||
|
starts once set-up is done; if it cannot listen, the bot exits with an
|
||||||
|
error, as when the chat client fails. Every request must carry the
|
||||||
|
credential, compared in constant time; every response carries headers
|
||||||
|
that forbid framing, content sniffing, caching and referrers, and a
|
||||||
|
`Permissions-Policy` that denies the camera, microphone and location;
|
||||||
|
a request body is capped at 64 KiB and a request's work at 10 seconds.
|
||||||
|
Handlers call the chat client on the request's own goroutine, never on
|
||||||
|
the one that delivers events, which also delivers the chat client's
|
||||||
|
answers. When the bot stops, requests in progress get 5 seconds to
|
||||||
|
finish before the chat client is stopped.
|
||||||
|
- **Webhooks** (`internal/api`) are read from `$DATA_DIR/webhooks.json`
|
||||||
|
before the chat client starts. Changes are made one at a time, and
|
||||||
|
each rewrites the file whole: the bot writes a temporary file beside
|
||||||
|
it, named `webhooks.json.` and digits, and renames it over the old
|
||||||
|
one. A crash leaves the old file or the new one, never part of one,
|
||||||
|
and at worst a stray temporary file, which the bot ignores. Messages
|
||||||
|
are posted to the webhooks by four goroutines serving a queue. The
|
||||||
|
goroutine that delivers the chat client's events, and also its
|
||||||
|
answers, only puts each message in the queue, never waiting: not for a
|
||||||
|
webhook, and not for the list of webhooks, which waits while the file
|
||||||
|
is written. Posting goes on while the bot stops, until the chat client
|
||||||
|
has exited.
|
||||||
- **Replies**: for each text message a contact sends in a direct chat,
|
- **Replies**: for each text message a contact sends in a direct chat,
|
||||||
the bot sends back the result, as a reply quoting the message. Group
|
the bot sends back the result, as a reply quoting the message. Group
|
||||||
messages, files and the bot's own messages are ignored.
|
messages, files and the bot's own messages are ignored.
|
||||||
- **Arithmetic** (`internal/calc`): the text is parsed as a Go
|
- **Arithmetic** (`internal/calc`): a small parser of its own reads
|
||||||
expression with `go/parser`, and only numbers, `+ - * /`, unary signs
|
numbers, `+ - * / % ^`, signs and parentheses, and refuses anything
|
||||||
and parentheses are evaluated; anything else in the syntax tree is
|
else. `go/constant` computes with exact rationals. `^`, also written
|
||||||
refused. `go/constant` computes with exact rationals. Numbers are read
|
`**`, is a power: it binds tighter than `*`, `/`, `%` and a sign on
|
||||||
as decimal, so `010` is ten. Input over 256 bytes is refused, so a
|
its left, and groups to the right, so `2^3^2` is `512`, `-2^2` is
|
||||||
|
`-4`, `(-2)^2` is `4` and `2^-1` is `0.5`. `%` is the remainder and
|
||||||
|
ranks with `*` and `/`; its result takes the sign of the divisor, as
|
||||||
|
in Python, so `7 % 3` is `1`, `-7 % 3` is `2` and `7.5 % 2` is `1.5`.
|
||||||
|
A power with a whole exponent is exact, so `0.1^2` is `0.01` and
|
||||||
|
`2^-1400 * 2^1400` is `1`, unless `go/constant` could hold the result
|
||||||
|
only rounded; that power, and one with a fractional exponent, is
|
||||||
|
computed as a double, so `2^0.5` is `1.4142135623730951`. A negative
|
||||||
|
number to a fractional power is refused, as having no real result.
|
||||||
|
Numbers are read as decimal, so `010` is ten. Input over 256 bytes is
|
||||||
|
refused, exact powers are capped, and every number is held as a
|
||||||
|
fraction, whole numbers too, under the 4096-bit limit below, so a
|
||||||
message cannot make the bot do unbounded work. Whole numbers below
|
message cannot make the bot do unbounded work. Whole numbers below
|
||||||
10<sup>21</sup> are written exactly; other results in the shortest
|
10<sup>21</sup> are written exactly. Other results inside the normal
|
||||||
form that reads back as the same double, in exponent notation from
|
range of a double, about 2.2e-308 to 1.8e308 in magnitude, where a
|
||||||
10<sup>21</sup> up and below 10<sup>-6</sup>. A result beyond the
|
double keeps all its digits, are written in the shortest form that
|
||||||
range of a double is refused as too large.
|
reads back as the same double, in exponent notation from
|
||||||
|
10<sup>21</sup> up and below 10<sup>-6</sup>. Results outside that
|
||||||
|
range are written from their exact value in exponent notation, rounded
|
||||||
|
to 17 significant digits, the most the shortest form of a double
|
||||||
|
takes, with trailing zeros dropped: `2^1200` is
|
||||||
|
`1.7218479456385751e+361`, `10^400` is `1e+400` and `2^-1074` is
|
||||||
|
`4.9406564584124654e-324`. Refused as too large or too small: any
|
||||||
|
number whose numerator or denominator reaches 4096 bits, wherever it
|
||||||
|
appears, as `go/constant` rounds a fraction that grows that large
|
||||||
|
(`2^4095`, `1e-1300 + 1`); and a power computed as a double whose
|
||||||
|
result is outside the normal range of a double (`2^1500.5`). The base
|
||||||
|
of such a power may be outside that range: square roots taken from its
|
||||||
|
exact value bring it inside first, so `(2^1200)^0.5` is
|
||||||
|
`4.149515568880993e+180` and `1e-400^0.5` is `1e-200`.
|
||||||
- **Failure is an exit.** If the chat client exits or the connection to
|
- **Failure is an exit.** If the chat client exits or the connection to
|
||||||
it drops, the bot exits with an error and the container's restart
|
it drops, the bot exits with an error and the container's restart
|
||||||
policy starts both again. `SIGTERM` stops the bot, which stops the
|
policy starts both again. `SIGTERM` stops the bot, which stops the
|
||||||
@@ -180,11 +508,14 @@ container.
|
|||||||
|
|
||||||
## Operating it
|
## Operating it
|
||||||
|
|
||||||
**Backup.** Everything durable is the SimpleX database on the volume:
|
**Backup.** Everything durable is on the volume: the SimpleX database,
|
||||||
`simplex_chat.db` and `simplex_agent.db`. Stop the container before
|
`simplex_chat.db` and `simplex_agent.db`; the API credential,
|
||||||
copying them, since a copy taken from under the running client can be
|
`api-token`; and the webhooks, `webhooks.json`. Stop the container
|
||||||
inconsistent. The database holds the bot's keys, so a copy lets its
|
before copying the database, since a copy taken from under the running
|
||||||
holder answer as the bot; keep it as private as the running instance.
|
client can be inconsistent. The database holds the bot's keys, so a copy
|
||||||
|
lets its holder answer as the bot; the credential lets its holder use
|
||||||
|
the API; and a webhook's URL can hold a secret of the program it points
|
||||||
|
at. Keep all three as private as the running instance.
|
||||||
|
|
||||||
**Upgrade.** Rebuild the image and recreate the container with the same
|
**Upgrade.** Rebuild the image and recreate the container with the same
|
||||||
volume. The chat client migrates its database on start. A newer
|
volume. The chat client migrates its database on start. A newer
|
||||||
|
|||||||
+3
-11
@@ -2,12 +2,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
)
|
|
||||||
|
|
||||||
// appname is the name the program reports in its logs and its version.
|
"sneak.berlin/go/simplexcalc/internal/cli"
|
||||||
const appname = "simplexcalc"
|
)
|
||||||
|
|
||||||
// version is injected at build time with -ldflags "-X main.version=...".
|
// version is injected at build time with -ldflags "-X main.version=...".
|
||||||
// The Dockerfile passes VERSION; a `go build` without it says "dev",
|
// The Dockerfile passes VERSION; a `go build` without it says "dev",
|
||||||
@@ -16,11 +14,5 @@ const appname = "simplexcalc"
|
|||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
err := rootCmd().Execute()
|
os.Exit(cli.Main(version))
|
||||||
if err != nil {
|
|
||||||
// cobra has already printed the error; this only sets the exit
|
|
||||||
// status, which is what a supervisor and a shell script read.
|
|
||||||
fmt.Fprintln(os.Stderr, "exiting: "+err.Error())
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ with no deprecation warning.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-29 Exact results past the range of a double, such as `2^1200`,
|
||||||
|
are written to 17 significant digits instead of being refused, and a
|
||||||
|
fractional power of such a number, such as `(2^1200)^0.5`, is answered
|
||||||
|
- 2026-09-29 Each new incoming message is posted to its chat's webhooks
|
||||||
|
- 2026-09-29 `POST`, `GET` and `DELETE` on a chat's webhooks, under
|
||||||
|
`/api/v1/chats/{id}/webhooks`, kept in `$DATA_DIR/webhooks.json`
|
||||||
|
- 2026-09-29 `GET` and `POST /api/v1/chats/{id}/messages`: a chat's
|
||||||
|
latest messages, and sending one
|
||||||
|
- 2026-09-29 Added the HTTP API in `internal/api`: the server on `PORT`,
|
||||||
|
the bearer credential from `API_TOKEN_FILE`, security headers and
|
||||||
|
limits, and `GET /api/v1/chats`
|
||||||
|
- 2026-09-28 Powers (`^`, also written `**`) and remainders (`%`) in
|
||||||
|
`internal/calc`, which now reads expressions with a parser of its own
|
||||||
|
in place of `go/parser`
|
||||||
|
- 2026-09-28 Moved the command tree and the `run` and `version` commands
|
||||||
|
from `cmd/simplexcalc/` into `internal/cli`; `cmd/simplexcalc/main.go`
|
||||||
|
is now a single call to `cli.Main`
|
||||||
- 2026-09-26 Seeded from `go-template-repo` (renamed, its HTTP service
|
- 2026-09-26 Seeded from `go-template-repo` (renamed, its HTTP service
|
||||||
removed) and built the bot: `internal/calc` for the arithmetic,
|
removed) and built the bot: `internal/calc` for the arithmetic,
|
||||||
`internal/simplex` for the chat client process and its WebSocket API,
|
`internal/simplex` for the chat client process and its WebSocket API,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module sneak.berlin/go/simplexcalc
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.3.2
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// Package api is the bot's HTTP API, through which another program
|
||||||
|
// reads the bot's chats, sends messages in them and registers webhooks
|
||||||
|
// on them. Deliveries posts to those webhooks each message a contact
|
||||||
|
// sends.
|
||||||
|
//
|
||||||
|
// Every request must carry the credential, as "Authorization: Bearer
|
||||||
|
// {credential}"; with no credential configured, every request is
|
||||||
|
// refused. No path is exempt.
|
||||||
|
//
|
||||||
|
// Handlers call the chat client on the request's own goroutine. Doing
|
||||||
|
// so from the chat client's event handler would wait forever, since
|
||||||
|
// that goroutine also delivers the responses (see simplex.EventHandler).
|
||||||
|
// For the same reason, the event handler hands messages to Deliveries
|
||||||
|
// without waiting.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxBodyBytes caps a request body.
|
||||||
|
maxBodyBytes = 64 << 10
|
||||||
|
|
||||||
|
// requestTimeout bounds the work behind one request, which is
|
||||||
|
// mostly waiting for the chat client.
|
||||||
|
requestTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// Limits on clients that send or read slowly. writeTimeout outlasts
|
||||||
|
// requestTimeout, so a handler that ran out of time can still
|
||||||
|
// answer.
|
||||||
|
readHeaderTimeout = 5 * time.Second
|
||||||
|
readTimeout = 10 * time.Second
|
||||||
|
writeTimeout = requestTimeout + 5*time.Second
|
||||||
|
idleTimeout = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatClient is the part of the chat client the API uses.
|
||||||
|
// *simplex.Client provides it.
|
||||||
|
type ChatClient interface {
|
||||||
|
// Contacts returns the contacts of the user userID.
|
||||||
|
Contacts(ctx context.Context, userID int64) ([]simplex.Contact, error)
|
||||||
|
|
||||||
|
// ChatItems returns the last count items of the chat with a
|
||||||
|
// contact, oldest first.
|
||||||
|
ChatItems(
|
||||||
|
ctx context.Context, contactID int64, count int,
|
||||||
|
) ([]simplex.ChatItem, error)
|
||||||
|
|
||||||
|
// SendMessage sends a text message to a contact and returns it.
|
||||||
|
SendMessage(
|
||||||
|
ctx context.Context, contactID int64, text string,
|
||||||
|
) (simplex.ChatItem, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params configures New.
|
||||||
|
type Params struct {
|
||||||
|
Log *slog.Logger
|
||||||
|
|
||||||
|
// Client is the chat client, and UserID the bot's user profile in
|
||||||
|
// it.
|
||||||
|
Client ChatClient
|
||||||
|
UserID int64
|
||||||
|
|
||||||
|
// Port is the TCP port to listen on, on all interfaces.
|
||||||
|
Port int
|
||||||
|
|
||||||
|
// Token is the credential every request must carry. Empty refuses
|
||||||
|
// every request.
|
||||||
|
Token string
|
||||||
|
|
||||||
|
// Webhooks holds the webhooks registered on the chats; ReadWebhooks
|
||||||
|
// makes it.
|
||||||
|
Webhooks *Webhooks
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns the API's server. The caller starts it with
|
||||||
|
// ListenAndServe and stops it with Shutdown.
|
||||||
|
func New(p Params) *http.Server {
|
||||||
|
if p.Token == "" {
|
||||||
|
p.Log.Warn("API_TOKEN_FILE is not set, so the API refuses every request")
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &handlers{
|
||||||
|
log: p.Log,
|
||||||
|
client: p.Client,
|
||||||
|
userID: p.UserID,
|
||||||
|
token: p.Token,
|
||||||
|
webhooks: p.Webhooks,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(securityHeaders, h.authenticate,
|
||||||
|
middleware.RequestSize(maxBodyBytes), withTimeout)
|
||||||
|
router.NotFound(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
h.respondError(w, http.StatusNotFound, "not found")
|
||||||
|
})
|
||||||
|
router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
h.respondError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
})
|
||||||
|
router.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Get("/chats", h.handleChats())
|
||||||
|
r.Get("/chats/{id}/messages", h.handleMessages())
|
||||||
|
r.Post("/chats/{id}/messages", h.handleSend())
|
||||||
|
r.Get("/chats/{id}/webhooks", h.handleWebhooks())
|
||||||
|
r.Post("/chats/{id}/webhooks", h.handleRegister())
|
||||||
|
r.Delete("/chats/{id}/webhooks/{webhook_id}", h.handleRemove())
|
||||||
|
})
|
||||||
|
|
||||||
|
return &http.Server{
|
||||||
|
Addr: ":" + strconv.Itoa(p.Port),
|
||||||
|
Handler: router,
|
||||||
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
|
ReadTimeout: readTimeout,
|
||||||
|
WriteTimeout: writeTimeout,
|
||||||
|
IdleTimeout: idleTimeout,
|
||||||
|
// Otherwise net/http answers "OPTIONS *" itself, with 200 and
|
||||||
|
// without the credential check or the headers.
|
||||||
|
DisableGeneralOptionsHandler: true,
|
||||||
|
// net/http's own messages, such as a handler's panic, go to the
|
||||||
|
// same JSON log as everything else.
|
||||||
|
ErrorLog: slog.NewLogLogger(p.Log.Handler(), slog.LevelError),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlers holds what the handlers share.
|
||||||
|
type handlers struct {
|
||||||
|
log *slog.Logger
|
||||||
|
client ChatClient
|
||||||
|
userID int64
|
||||||
|
token string
|
||||||
|
webhooks *Webhooks
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticate lets a request through only if it carries the
|
||||||
|
// credential. With no credential it refuses everything, and must:
|
||||||
|
// ConstantTimeCompare finds two empty strings equal, so an empty bearer
|
||||||
|
// would get in.
|
||||||
|
func (h *handlers) authenticate(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scheme, credential, _ := strings.Cut(r.Header.Get("Authorization"), " ")
|
||||||
|
|
||||||
|
if h.token == "" || !strings.EqualFold(scheme, "Bearer") ||
|
||||||
|
subtle.ConstantTimeCompare([]byte(credential), []byte(h.token)) != 1 {
|
||||||
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||||
|
h.respondError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// respond sends v as the JSON body of a response with status.
|
||||||
|
func (h *handlers) respond(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
|
err := json.NewEncoder(w).Encode(v)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Warn("sending a response", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondError sends status with a chosen sentence. An error's own text
|
||||||
|
// never goes to the client, since it can describe the machine; it goes
|
||||||
|
// to the log.
|
||||||
|
func (h *handlers) respondError(w http.ResponseWriter, status int, sentence string) {
|
||||||
|
h.respond(w, status, struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}{sentence})
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeBody decodes the request's JSON body into v. If the body is too
|
||||||
|
// large, or is not JSON of v's shape, it answers the request itself, 413
|
||||||
|
// or 400 naming example as the shape wanted, and returns false.
|
||||||
|
func (h *handlers) decodeBody(
|
||||||
|
w http.ResponseWriter, r *http.Request, v any, example string,
|
||||||
|
) bool {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
|
||||||
|
var tooLarge *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooLarge) {
|
||||||
|
h.respondError(w, http.StatusRequestEntityTooLarge, "the body is too large")
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil || json.Unmarshal(body, v) != nil {
|
||||||
|
h.respondError(w, http.StatusBadRequest, "the body must be JSON such as "+example)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// securityHeaders go on every response. The API returns JSON to
|
||||||
|
// programs, so a browser may not frame, sniff, cache or refer from it,
|
||||||
|
// nor give it the camera, microphone or location, and must reach it
|
||||||
|
// over HTTPS.
|
||||||
|
func securityHeaders(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
header := w.Header()
|
||||||
|
header.Set("X-Content-Type-Options", "nosniff")
|
||||||
|
header.Set("Content-Security-Policy",
|
||||||
|
"default-src 'none'; frame-ancestors 'none'")
|
||||||
|
header.Set("X-Frame-Options", "DENY")
|
||||||
|
header.Set("Referrer-Policy", "no-referrer")
|
||||||
|
header.Set("Permissions-Policy",
|
||||||
|
"camera=(), microphone=(), geolocation=()")
|
||||||
|
header.Set("Strict-Transport-Security",
|
||||||
|
"max-age=31536000; includeSubDomains")
|
||||||
|
header.Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTimeout ends each request's context after requestTimeout, so a
|
||||||
|
// handler waiting on the chat client gives up and answers.
|
||||||
|
func withTimeout(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// queueLength is how many messages can wait to be posted, and
|
||||||
|
// deliveryGoroutines how many goroutines post them, each one message
|
||||||
|
// at a time.
|
||||||
|
queueLength = 100
|
||||||
|
deliveryGoroutines = 4
|
||||||
|
|
||||||
|
// deliveryTimeout bounds one POST to a webhook.
|
||||||
|
deliveryTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// delivery is the body of the POST to each webhook of a chat: a message a
|
||||||
|
// contact sent in it, as GET /api/v1/chats/{id}/messages shows it.
|
||||||
|
type delivery struct {
|
||||||
|
ChatID int64 `json:"chat_id"`
|
||||||
|
Message message `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliveries posts each message a contact sends to each webhook of its
|
||||||
|
// chat, once, with no retry. Add hands it a message without waiting: the
|
||||||
|
// message joins a queue served by deliveryGoroutines goroutines, or is
|
||||||
|
// dropped if the queue is full. What goes wrong is logged with ids only:
|
||||||
|
// never a webhook's URL, which can hold a secret, nor a message's text.
|
||||||
|
type Deliveries struct {
|
||||||
|
log *slog.Logger
|
||||||
|
webhooks *Webhooks
|
||||||
|
client *http.Client
|
||||||
|
queue chan delivery
|
||||||
|
|
||||||
|
// stop ends the goroutines' context, abandoning the POSTs in
|
||||||
|
// progress; wg waits for the goroutines to return.
|
||||||
|
stop context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartDeliveries starts posting the messages Add is given, until ctx
|
||||||
|
// ends or Stop is called.
|
||||||
|
func StartDeliveries(
|
||||||
|
ctx context.Context, log *slog.Logger, webhooks *Webhooks,
|
||||||
|
) *Deliveries {
|
||||||
|
ctx, stop := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
d := &Deliveries{
|
||||||
|
log: log,
|
||||||
|
webhooks: webhooks,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: deliveryTimeout,
|
||||||
|
// A redirect is not followed, and so counts as a status
|
||||||
|
// other than 2xx: following it could turn the POST into a
|
||||||
|
// GET without the message.
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
},
|
||||||
|
queue: make(chan delivery, queueLength),
|
||||||
|
stop: stop,
|
||||||
|
}
|
||||||
|
|
||||||
|
for range deliveryGoroutines {
|
||||||
|
d.wg.Go(func() { d.serve(ctx) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add queues the message in item for the webhooks of its chat, if it is a
|
||||||
|
// message a contact sent in a direct chat: one that GET
|
||||||
|
// /api/v1/chats/{id}/messages shows as received. The chat client's event
|
||||||
|
// handler calls it, so it never waits: if the queue is full, the message
|
||||||
|
// is dropped, and the log says so.
|
||||||
|
func (d *Deliveries) Add(item simplex.AChatItem) {
|
||||||
|
contact := item.ChatInfo.Contact
|
||||||
|
|
||||||
|
m, ok := newMessage(item.ChatItem)
|
||||||
|
if !ok || m.Direction != "received" || item.ChatInfo.Type != "direct" ||
|
||||||
|
contact == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case d.queue <- delivery{ChatID: contact.ContactID, Message: m}:
|
||||||
|
default:
|
||||||
|
d.log.Warn("dropping a message for the webhooks: the queue is full",
|
||||||
|
"chat_id", contact.ContactID, "message_id", m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops posting, and returns once the goroutines have: POSTs in
|
||||||
|
// progress are abandoned, and messages still queued are not posted.
|
||||||
|
func (d *Deliveries) Stop() {
|
||||||
|
d.stop()
|
||||||
|
d.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve posts queued messages, one at a time, until ctx ends.
|
||||||
|
func (d *Deliveries) serve(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case m := <-d.queue:
|
||||||
|
d.deliver(ctx, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver posts m to each webhook of its chat, and logs each POST that
|
||||||
|
// fails or is answered with a status other than 2xx. The webhooks are
|
||||||
|
// read here rather than in Add, as reading them waits while the webhooks
|
||||||
|
// file is written.
|
||||||
|
func (d *Deliveries) deliver(ctx context.Context, m delivery) {
|
||||||
|
body, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
d.log.Error("encoding a message for the webhooks",
|
||||||
|
"message_id", m.Message.ID, "error", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hook := range d.webhooks.list(m.ChatID) {
|
||||||
|
// Stopped: the chat's other webhooks do not get the message.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := d.post(ctx, hook.URL, body)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
d.log.Warn("posting a message to a webhook",
|
||||||
|
"webhook_id", hook.ID, "message_id", m.Message.ID, "error", err)
|
||||||
|
case status < http.StatusOK || status >= http.StatusMultipleChoices:
|
||||||
|
d.log.Warn("posting a message to a webhook",
|
||||||
|
"webhook_id", hook.ID, "message_id", m.Message.ID, "status", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// post sends body to hookURL, once, and returns the status of the answer.
|
||||||
|
// Its error, unlike net/http's, never holds the URL.
|
||||||
|
func (d *Deliveries) post(
|
||||||
|
ctx context.Context, hookURL string, body []byte,
|
||||||
|
) (int, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hookURL,
|
||||||
|
bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return 0, withoutURL(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := d.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, withoutURL(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
return resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutURL returns the cause of a *url.Error, whose own text holds the
|
||||||
|
// URL, and any other error as it is.
|
||||||
|
func withoutURL(err error) error {
|
||||||
|
var urlErr *url.Error
|
||||||
|
if errors.As(err, &urlErr) {
|
||||||
|
return urlErr.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/api"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newChatItems is a newChatItems event, reduced to the fields the bot
|
||||||
|
// reads. It holds, in this order, a message in a group, a message the bot
|
||||||
|
// sent, an event the chat client records in a chat, and two messages a
|
||||||
|
// contact sent: a text and a picture with a caption.
|
||||||
|
const newChatItems = `{"type":"newChatItems","chatItems":[
|
||||||
|
{"chatInfo":{"type":"group","groupInfo":{"groupId":9}},
|
||||||
|
"chatItem":{"chatDir":{"type":"groupRcv"},"meta":{"itemId":5},
|
||||||
|
"content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"1 + 1"}}}},
|
||||||
|
{"chatInfo":{"type":"direct","contact":{"contactId":3}},
|
||||||
|
"chatItem":{"chatDir":{"type":"directSnd"},"meta":{"itemId":6},
|
||||||
|
"content":{"type":"sndMsgContent","msgContent":{"type":"text","text":"2"}}}},
|
||||||
|
{"chatInfo":{"type":"direct","contact":{"contactId":3}},
|
||||||
|
"chatItem":{"chatDir":{"type":"directRcv"},"meta":{"itemId":7},
|
||||||
|
"content":{"type":"rcvChatFeature","feature":"calls"}}},
|
||||||
|
{"chatInfo":{"type":"direct","contact":{"contactId":3}},
|
||||||
|
"chatItem":{"chatDir":{"type":"directRcv"},
|
||||||
|
"meta":{"itemId":9,"itemTs":"2026-09-29T03:14:34Z"},
|
||||||
|
"content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"2 + 2"}}}},
|
||||||
|
{"chatInfo":{"type":"direct","contact":{"contactId":3}},
|
||||||
|
"chatItem":{"chatDir":{"type":"directRcv"},
|
||||||
|
"meta":{"itemId":11,"itemTs":"2026-09-29T03:15:02Z"},
|
||||||
|
"content":{"type":"rcvMsgContent","msgContent":{"type":"image",
|
||||||
|
"text":"a picture","image":"data:image/jpg;base64,/9j/4AAQ"}}}}]}`
|
||||||
|
|
||||||
|
// textItem is the index in newChatItems of the text the contact sent.
|
||||||
|
const textItem = 3
|
||||||
|
|
||||||
|
// logBuffer is a log that the goroutines posting messages write to while
|
||||||
|
// the test reads it.
|
||||||
|
type logBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *logBuffer) Write(p []byte) (int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
return b.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *logBuffer) String() string {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
return b.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatItems returns the chat items in a newChatItems record.
|
||||||
|
func chatItems(t *testing.T, record string) []simplex.AChatItem {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var r simplex.NewChatItems
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(record), &r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding %s: %v", record, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.ChatItems
|
||||||
|
}
|
||||||
|
|
||||||
|
// receive returns the next of posts, and fails the test if none comes
|
||||||
|
// within 5 seconds.
|
||||||
|
func receive(t *testing.T, posts <-chan string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case p := <-posts:
|
||||||
|
return p
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("nothing more was posted")
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// within runs f, and fails the test with complaint if f has not returned
|
||||||
|
// within 5 seconds.
|
||||||
|
func within(t *testing.T, complaint string, f func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
f()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal(complaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliveries: each message a contact sends is posted, as JSON with
|
||||||
|
// its chat's id, to each webhook of its chat and to no other chat's.
|
||||||
|
// Messages in groups, the bot's own messages and the events the chat
|
||||||
|
// client records in a chat are not posted.
|
||||||
|
func TestDeliveries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
posts := make(chan string, 10)
|
||||||
|
receiver := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
posts <- r.Method + " " + r.URL.Path + " " +
|
||||||
|
r.Header.Get("Content-Type") + " " + string(body)
|
||||||
|
}))
|
||||||
|
t.Cleanup(receiver.Close)
|
||||||
|
|
||||||
|
webhooks := readWebhooks(t, t.TempDir())
|
||||||
|
srv := webhookAPI(webhooks)
|
||||||
|
|
||||||
|
// Chat 4's webhook comes between chat 3's, so a message posted to
|
||||||
|
// every webhook in turn would reach it before chat 3's second.
|
||||||
|
register(t, srv, webhooksPath, receiver.URL+"/a", http.StatusCreated)
|
||||||
|
register(t, srv, "/api/v1/chats/4/webhooks", receiver.URL+"/c", http.StatusCreated)
|
||||||
|
register(t, srv, webhooksPath, receiver.URL+"/b", http.StatusCreated)
|
||||||
|
|
||||||
|
deliveries := api.StartDeliveries(t.Context(), slog.New(slog.DiscardHandler),
|
||||||
|
webhooks)
|
||||||
|
t.Cleanup(deliveries.Stop)
|
||||||
|
|
||||||
|
for _, item := range chatItems(t, newChatItems) {
|
||||||
|
deliveries.Add(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := `{"chat_id":3,"message":{"id":9,"direction":"received",` +
|
||||||
|
`"type":"text","text":"2 + 2","time":"2026-09-29T03:14:34Z"}}`
|
||||||
|
picture := `{"chat_id":3,"message":{"id":11,"direction":"received",` +
|
||||||
|
`"type":"image","text":"a picture","time":"2026-09-29T03:15:02Z"}}`
|
||||||
|
want := []string{
|
||||||
|
"POST /a application/json " + text,
|
||||||
|
"POST /a application/json " + picture,
|
||||||
|
"POST /b application/json " + text,
|
||||||
|
"POST /b application/json " + picture,
|
||||||
|
}
|
||||||
|
got := make([]string, 0, len(want)+1)
|
||||||
|
|
||||||
|
for range want {
|
||||||
|
got = append(got, receive(t, posts))
|
||||||
|
}
|
||||||
|
|
||||||
|
deliveries.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case p := <-posts:
|
||||||
|
got = append(got, p)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(want)
|
||||||
|
slices.Sort(got)
|
||||||
|
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("posted:\n%s\nwant:\n%s",
|
||||||
|
strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliveriesQueueFull: while a webhook holds a POST from each of the
|
||||||
|
// goroutines, Add queues as many messages as the queue takes, then drops
|
||||||
|
// the next rather than wait, logging it by its chat's id and its own,
|
||||||
|
// never its text. Stop abandons the POSTs in progress rather than wait
|
||||||
|
// for them.
|
||||||
|
func TestDeliveriesQueueFull(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
held := make(chan string, api.DeliveryGoroutines)
|
||||||
|
receiver := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
// Read to the end: only then does the server notice when the
|
||||||
|
// bot abandons the POST, and end r's context.
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
held <- string(body)
|
||||||
|
|
||||||
|
<-r.Context().Done() // never answers
|
||||||
|
}))
|
||||||
|
t.Cleanup(receiver.Close)
|
||||||
|
|
||||||
|
webhooks := readWebhooks(t, t.TempDir())
|
||||||
|
register(t, webhookAPI(webhooks), webhooksPath, receiver.URL, http.StatusCreated)
|
||||||
|
|
||||||
|
var logged logBuffer
|
||||||
|
|
||||||
|
deliveries := api.StartDeliveries(t.Context(),
|
||||||
|
slog.New(slog.NewJSONHandler(&logged, nil)), webhooks)
|
||||||
|
t.Cleanup(deliveries.Stop)
|
||||||
|
|
||||||
|
// A message for each goroutine, whose POST the webhook holds, then as
|
||||||
|
// many as the queue takes, then one more.
|
||||||
|
items := make([]simplex.AChatItem, api.DeliveryGoroutines+api.QueueLength+1)
|
||||||
|
for i := range items {
|
||||||
|
items[i] = chatItems(t, newChatItems)[textItem]
|
||||||
|
items[i].ChatItem.Meta.ItemID = int64(i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items[:api.DeliveryGoroutines] {
|
||||||
|
deliveries.Add(item)
|
||||||
|
receive(t, held)
|
||||||
|
}
|
||||||
|
|
||||||
|
within(t, "Add waited for room in the queue", func() {
|
||||||
|
for _, item := range items[api.DeliveryGoroutines:] {
|
||||||
|
deliveries.Add(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
within(t, "Stop waited for the webhook to answer", deliveries.Stop)
|
||||||
|
|
||||||
|
log := logged.String()
|
||||||
|
dropped := `"level":"WARN","msg":"dropping a message for the webhooks: ` +
|
||||||
|
`the queue is full","chat_id":3,"message_id":`
|
||||||
|
|
||||||
|
if strings.Count(log, dropped) != 1 ||
|
||||||
|
!strings.Contains(log, dropped+strconv.Itoa(len(items))+"}") {
|
||||||
|
t.Errorf("log = %s, want message %d dropped, and no other", log, len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(log, "2 + 2") {
|
||||||
|
t.Errorf("log = %s, holding a message's text", log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliveryFailures: a POST that fails, or is answered with a status
|
||||||
|
// other than 2xx, is logged with the webhook's id, the message's and the
|
||||||
|
// reason or the status, and is not made again. A redirect is not
|
||||||
|
// followed. The log holds neither the webhook's URL nor the message's
|
||||||
|
// text.
|
||||||
|
func TestDeliveryFailures(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
requested := make(chan string, 10)
|
||||||
|
receiver := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requested <- r.URL.Path
|
||||||
|
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/refuses":
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
case "/redirects":
|
||||||
|
http.Redirect(w, r, "/elsewhere", http.StatusFound)
|
||||||
|
case "/hangs-up":
|
||||||
|
conn, _, err := http.NewResponseController(w).Hijack()
|
||||||
|
if err == nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(receiver.Close)
|
||||||
|
|
||||||
|
webhooks := readWebhooks(t, t.TempDir())
|
||||||
|
paths := []string{"/hangs-up", "/redirects", "/refuses"}
|
||||||
|
ids := map[string]string{}
|
||||||
|
|
||||||
|
// The query stands for a secret that a webhook's URL can hold.
|
||||||
|
for _, path := range paths {
|
||||||
|
ids[path] = register(t, webhookAPI(webhooks), webhooksPath,
|
||||||
|
receiver.URL+path+"?key=s3cret", http.StatusCreated).ID
|
||||||
|
}
|
||||||
|
|
||||||
|
var logged logBuffer
|
||||||
|
|
||||||
|
deliveries := api.StartDeliveries(t.Context(),
|
||||||
|
slog.New(slog.NewJSONHandler(&logged, nil)), webhooks)
|
||||||
|
t.Cleanup(deliveries.Stop)
|
||||||
|
|
||||||
|
deliveries.Add(chatItems(t, newChatItems)[textItem])
|
||||||
|
|
||||||
|
failed := `"level":"WARN","msg":"posting a message to a webhook","webhook_id":"`
|
||||||
|
|
||||||
|
within(t, "the three failures were not logged", func() {
|
||||||
|
for strings.Count(logged.String(), failed) < len(paths) {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
log := logged.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
failed + ids["/hangs-up"] + `","message_id":9,"error":"`,
|
||||||
|
failed + ids["/redirects"] + `","message_id":9,"status":302}`,
|
||||||
|
failed + ids["/refuses"] + `","message_id":9,"status":503}`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(log, want) {
|
||||||
|
t.Errorf("log = %s, want it to hold %s", log, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, secret := range []string{receiver.URL, "s3cret", "2 + 2"} {
|
||||||
|
if strings.Contains(log, secret) {
|
||||||
|
t.Errorf("log = %s, holding %s", log, secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each webhook was asked once, before its failure was logged, and
|
||||||
|
// the redirect's target never.
|
||||||
|
got := []string{<-requested, <-requested, <-requested}
|
||||||
|
slices.Sort(got)
|
||||||
|
|
||||||
|
if !slices.Equal(got, paths) || len(requested) != 0 {
|
||||||
|
t.Errorf("requested %v and %d more, want %v once each",
|
||||||
|
got, len(requested), paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// DeliveryGoroutines and QueueLength are deliveryGoroutines and
|
||||||
|
// queueLength, exported for the external test package, which fills the
|
||||||
|
// queue.
|
||||||
|
const (
|
||||||
|
DeliveryGoroutines = deliveryGoroutines
|
||||||
|
QueueLength = queueLength
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/api"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
hookURL = "https://example.com/hook"
|
||||||
|
|
||||||
|
// unknownID is shaped like a webhook's id, and no webhook has it.
|
||||||
|
unknownID = "00112233445566778899aabbccddeeff"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hook is a webhook as the API answers with it.
|
||||||
|
type hook struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ChatID int64 `json:"chat_id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns h as the API writes it.
|
||||||
|
func (h hook) String() string {
|
||||||
|
return `{"id":"` + h.ID + `","chat_id":` + strconv.FormatInt(h.ChatID, 10) +
|
||||||
|
`,"url":"` + h.URL + `"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// twoChats returns the bot's contacts in these tests: chat 3, which
|
||||||
|
// webhooksPath names, and chat 4, whose contact has deleted it.
|
||||||
|
func twoChats() []simplex.Contact {
|
||||||
|
return append(oneChat(), simplex.Contact{ContactID: 4, Status: "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// readWebhooks returns the webhooks kept in dir.
|
||||||
|
func readWebhooks(t *testing.T, dir string) *api.Webhooks {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhooks, err := api.ReadWebhooks(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return webhooks
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookAPI returns an API with webhooks, whose chats are those of
|
||||||
|
// twoChats.
|
||||||
|
func webhookAPI(webhooks *api.Webhooks) *http.Server {
|
||||||
|
return api.New(api.Params{
|
||||||
|
Log: slog.New(slog.DiscardHandler),
|
||||||
|
Client: &fakeClient{contacts: twoChats()},
|
||||||
|
UserID: 1,
|
||||||
|
Port: 8080,
|
||||||
|
Token: credential,
|
||||||
|
Webhooks: webhooks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// register registers u on the chat whose webhooks are at path, and
|
||||||
|
// returns the webhook answered with. The answer must have status.
|
||||||
|
func register(t *testing.T, srv *http.Server, path, u string, status int) hook {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rec := post(t, srv, path, `{"url":"`+u+`"}`)
|
||||||
|
|
||||||
|
var h hook
|
||||||
|
|
||||||
|
err := json.Unmarshal(rec.Body.Bytes(), &h)
|
||||||
|
if err != nil || rec.Code != status || rec.Body.String() != h.String()+"\n" {
|
||||||
|
t.Fatalf("registering %s: %d %q, want %d and a webhook",
|
||||||
|
u, rec.Code, rec.Body.String(), status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// listed fails the test unless the chat whose webhooks are at path lists
|
||||||
|
// want, in that order.
|
||||||
|
func listed(t *testing.T, srv *http.Server, path string, want ...hook) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hooks := make([]string, 0, len(want))
|
||||||
|
for _, h := range want {
|
||||||
|
hooks = append(hooks, h.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
body := `{"webhooks":[` + strings.Join(hooks, ",") + "]}\n"
|
||||||
|
|
||||||
|
rec := request(t, srv, http.MethodGet, path, bearer)
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != body {
|
||||||
|
t.Errorf("GET %s: %d %q, want 200 %q", path, rec.Code, rec.Body.String(), body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eachEndpoint lists the webhooks of the chat whose webhooks are at path,
|
||||||
|
// registers one and removes one, and returns the three answers.
|
||||||
|
func eachEndpoint(
|
||||||
|
t *testing.T, srv *http.Server, path string,
|
||||||
|
) []*httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return []*httptest.ResponseRecorder{
|
||||||
|
request(t, srv, http.MethodGet, path, bearer),
|
||||||
|
post(t, srv, path, `{"url":"`+hookURL+`"}`),
|
||||||
|
request(t, srv, http.MethodDelete, path+"/"+unknownID, bearer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyFile fails the test unless dir holds the webhooks file and nothing
|
||||||
|
// else: no temporary file is left.
|
||||||
|
func onlyFile(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil || len(entries) != 1 || entries[0].Name() != "webhooks.json" {
|
||||||
|
t.Errorf("the directory holds %v (%v), want webhooks.json alone", entries, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noFile fails the test if dir has a webhooks file.
|
||||||
|
func noFile(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := os.Stat(filepath.Join(dir, "webhooks.json"))
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("webhooks.json: %v, want it not written", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhooks: a URL registered on a chat gets 201 and a new id of 32
|
||||||
|
// hexadecimal digits; the same URL again gets 200 and the same webhook.
|
||||||
|
// Each chat lists its own, in the order registered, the chat of a contact
|
||||||
|
// who deleted it included.
|
||||||
|
func TestWebhooks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := webhookAPI(readWebhooks(t, t.TempDir()))
|
||||||
|
|
||||||
|
listed(t, srv, webhooksPath)
|
||||||
|
|
||||||
|
first := register(t, srv, webhooksPath, hookURL, http.StatusCreated)
|
||||||
|
again := register(t, srv, webhooksPath, hookURL, http.StatusOK)
|
||||||
|
second := register(t, srv, webhooksPath, hookURL+"/2", http.StatusCreated)
|
||||||
|
other := register(t, srv, "/api/v1/chats/4/webhooks", hookURL, http.StatusCreated)
|
||||||
|
|
||||||
|
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(first.ID) ||
|
||||||
|
first.ChatID != 3 || first.URL != hookURL {
|
||||||
|
t.Errorf("registered %v, want a new id, chat 3 and %s", first, hookURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if again != first {
|
||||||
|
t.Errorf("registered again: %v, want %v", again, first)
|
||||||
|
}
|
||||||
|
|
||||||
|
if second.ID == first.ID || other.ID == first.ID || other.ID == second.ID {
|
||||||
|
t.Errorf("ids repeat: %v, %v, %v", first, second, other)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed(t, srv, webhooksPath, first, second)
|
||||||
|
listed(t, srv, "/api/v1/chats/4/webhooks", other)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveWebhook: a removed webhook gets 204 with no body and is no
|
||||||
|
// longer listed. Removing it again, removing a webhook through a chat it
|
||||||
|
// is not on, or removing an id no webhook has, gets 404.
|
||||||
|
func TestRemoveWebhook(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := webhookAPI(readWebhooks(t, t.TempDir()))
|
||||||
|
first := register(t, srv, webhooksPath, hookURL, http.StatusCreated)
|
||||||
|
second := register(t, srv, webhooksPath, hookURL+"/2", http.StatusCreated)
|
||||||
|
|
||||||
|
rec := request(t, srv, http.MethodDelete, webhooksPath+"/"+first.ID, bearer)
|
||||||
|
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
||||||
|
t.Errorf("removing: %d %q, want 204 and no body", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
noSuchWebhook := `{"error":"no such webhook"}` + "\n"
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
webhooksPath + "/" + first.ID,
|
||||||
|
"/api/v1/chats/4/webhooks/" + second.ID,
|
||||||
|
webhooksPath + "/" + unknownID,
|
||||||
|
} {
|
||||||
|
rec := request(t, srv, http.MethodDelete, path, bearer)
|
||||||
|
if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchWebhook {
|
||||||
|
t.Errorf("DELETE %s: %d %q, want 404 %q",
|
||||||
|
path, rec.Code, rec.Body.String(), noSuchWebhook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listed(t, srv, webhooksPath, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterRefused: a body that is not JSON with a URL a webhook can
|
||||||
|
// have, or that is over 64 KiB, registers nothing. A URL of 2048 bytes
|
||||||
|
// is registered.
|
||||||
|
func TestRegisterRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
notJSON := `{"error":"the body must be JSON such as ` +
|
||||||
|
`{\"url\":\"https://example.com/hook\"}"}` + "\n"
|
||||||
|
badURL := `{"error":"url must be an http or https URL with a host, ` +
|
||||||
|
`at most 2048 bytes long"}` + "\n"
|
||||||
|
longest := hookURL + "/" + strings.Repeat("a", 2048-len(hookURL)-1)
|
||||||
|
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
body string
|
||||||
|
status int
|
||||||
|
answer string
|
||||||
|
}{
|
||||||
|
"no body": {"", http.StatusBadRequest, notJSON},
|
||||||
|
"a bare URL": {hookURL, http.StatusBadRequest, notJSON},
|
||||||
|
"url not a string": {`{"url":5}`, http.StatusBadRequest, notJSON},
|
||||||
|
"no url": {`{}`, http.StatusBadRequest, badURL},
|
||||||
|
"relative": {`{"url":"/hook"}`, http.StatusBadRequest, badURL},
|
||||||
|
"no scheme": {`{"url":"example.com/hook"}`, http.StatusBadRequest, badURL},
|
||||||
|
"another scheme": {`{"url":"ftp://example.com/"}`, http.StatusBadRequest, badURL},
|
||||||
|
"no host": {`{"url":"https:///hook"}`, http.StatusBadRequest, badURL},
|
||||||
|
"a port, no host": {`{"url":"http://:80/hook"}`, http.StatusBadRequest, badURL},
|
||||||
|
"not a URL": {`{"url":"https://a b/"}`, http.StatusBadRequest, badURL},
|
||||||
|
"over 2048 bytes": {`{"url":"` + longest + `a"}`, http.StatusBadRequest, badURL},
|
||||||
|
"over 64 KiB": {
|
||||||
|
`{"url":"` + hookURL + "/" + strings.Repeat("a", 64<<10) + `"}`,
|
||||||
|
http.StatusRequestEntityTooLarge, `{"error":"the body is too large"}` + "\n",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
rec := post(t, webhookAPI(readWebhooks(t, dir)), webhooksPath, tc.body)
|
||||||
|
|
||||||
|
if rec.Code != tc.status || rec.Body.String() != tc.answer {
|
||||||
|
t.Errorf("response = %d %q, want %d %q",
|
||||||
|
rec.Code, rec.Body.String(), tc.status, tc.answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
noFile(t, dir)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
register(t, webhookAPI(readWebhooks(t, t.TempDir())), webhooksPath, longest,
|
||||||
|
http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhooksNoSuchChat: an id that GET /api/v1/chats does not list gets
|
||||||
|
// 404 from each webhook endpoint, 1 and 2 included, and nothing is
|
||||||
|
// written. When the chats cannot be read to look the id up, the answer
|
||||||
|
// is 500.
|
||||||
|
func TestWebhooksNoSuchChat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, id := range []string{
|
||||||
|
"1", "2", "5", "tester", "0", "-3", "3.5", "99999999999999999999",
|
||||||
|
} {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
for _, rec := range eachEndpoint(t, webhookAPI(readWebhooks(t, dir)),
|
||||||
|
"/api/v1/chats/"+id+"/webhooks") {
|
||||||
|
if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchChat {
|
||||||
|
t.Errorf("chat %q: %d %q, want 404 %q",
|
||||||
|
id, rec.Code, rec.Body.String(), noSuchChat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
noFile(t, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := api.New(api.Params{
|
||||||
|
Log: slog.New(slog.DiscardHandler),
|
||||||
|
Client: &fakeClient{contactsErr: errChat},
|
||||||
|
Token: credential,
|
||||||
|
Webhooks: readWebhooks(t, t.TempDir()),
|
||||||
|
})
|
||||||
|
want := `{"error":"the chats could not be read"}` + "\n"
|
||||||
|
|
||||||
|
for _, rec := range eachEndpoint(t, srv, webhooksPath) {
|
||||||
|
if rec.Code != http.StatusInternalServerError || rec.Body.String() != want {
|
||||||
|
t.Errorf("chats unreadable: %d %q, want 500 %q",
|
||||||
|
rec.Code, rec.Body.String(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhooksKept: a new read of the directory finds the webhooks
|
||||||
|
// registered, and none once they are removed.
|
||||||
|
func TestWebhooksKept(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
srv := webhookAPI(readWebhooks(t, dir))
|
||||||
|
first := register(t, srv, webhooksPath, hookURL, http.StatusCreated)
|
||||||
|
second := register(t, srv, "/api/v1/chats/4/webhooks", hookURL, http.StatusCreated)
|
||||||
|
|
||||||
|
srv = webhookAPI(readWebhooks(t, dir))
|
||||||
|
listed(t, srv, webhooksPath, first)
|
||||||
|
listed(t, srv, "/api/v1/chats/4/webhooks", second)
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
webhooksPath + "/" + first.ID, "/api/v1/chats/4/webhooks/" + second.ID,
|
||||||
|
} {
|
||||||
|
rec := request(t, srv, http.MethodDelete, path, bearer)
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("DELETE %s: %d %q, want 204", path, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv = webhookAPI(readWebhooks(t, dir))
|
||||||
|
listed(t, srv, webhooksPath)
|
||||||
|
listed(t, srv, "/api/v1/chats/4/webhooks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhooksFile: the file has mode 0600, and a change replaces it with
|
||||||
|
// a new file rather than writing into it: through a second name, the old
|
||||||
|
// file still holds what it held. No temporary file is left.
|
||||||
|
func TestWebhooksFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
file := filepath.Join(dir, "webhooks.json")
|
||||||
|
srv := webhookAPI(readWebhooks(t, dir))
|
||||||
|
register(t, srv, webhooksPath, hookURL, http.StatusCreated)
|
||||||
|
|
||||||
|
info, err := os.Stat(file)
|
||||||
|
if err != nil || info.Mode() != 0o600 {
|
||||||
|
t.Errorf("webhooks.json: %v (%v), want mode 0600", info, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before, err := os.ReadFile(file) //nolint:gosec // G304: the test's own file.
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
old := filepath.Join(t.TempDir(), "old")
|
||||||
|
|
||||||
|
err = os.Link(file, old)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
register(t, srv, webhooksPath, hookURL+"/2", http.StatusCreated)
|
||||||
|
|
||||||
|
after, err := os.ReadFile(old) //nolint:gosec // G304: the test's own file.
|
||||||
|
if err != nil || !bytes.Equal(after, before) {
|
||||||
|
t.Errorf("the old file holds %q (%v), want %q as before", after, err, before)
|
||||||
|
}
|
||||||
|
|
||||||
|
onlyFile(t, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhooksWriteFailure: a change whose file cannot be replaced gets
|
||||||
|
// 500 and is not made, and its temporary file is removed. A directory
|
||||||
|
// stands where the file goes, which a rename cannot replace; a directory
|
||||||
|
// without write permission would not do, as tests may run as root.
|
||||||
|
func TestWebhooksWriteFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
file := filepath.Join(dir, "webhooks.json")
|
||||||
|
srv := webhookAPI(readWebhooks(t, dir))
|
||||||
|
first := register(t, srv, webhooksPath, hookURL, http.StatusCreated)
|
||||||
|
|
||||||
|
err := os.Remove(file)
|
||||||
|
if err == nil {
|
||||||
|
err = os.Mkdir(file, 0o700)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := `{"error":"the webhooks could not be saved"}` + "\n"
|
||||||
|
|
||||||
|
for _, rec := range []*httptest.ResponseRecorder{
|
||||||
|
post(t, srv, webhooksPath, `{"url":"`+hookURL+`/2"}`),
|
||||||
|
request(t, srv, http.MethodDelete, webhooksPath+"/"+first.ID, bearer),
|
||||||
|
} {
|
||||||
|
if rec.Code != http.StatusInternalServerError || rec.Body.String() != want {
|
||||||
|
t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listed(t, srv, webhooksPath, first)
|
||||||
|
onlyFile(t, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadWebhooksRefuses: a webhooks file that holds anything but
|
||||||
|
// webhooks as the bot writes them cannot be read, and the error names
|
||||||
|
// it. An absent file is no webhooks.
|
||||||
|
func TestReadWebhooksRefuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
record := func(id, chatID, url string) string {
|
||||||
|
return `{"webhooks":[{"id":"` + id + `","chat_id":` + chatID +
|
||||||
|
`,"url":"` + url + `"}]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, contents := range map[string]string{
|
||||||
|
"empty file": "",
|
||||||
|
"a word": "webhooks",
|
||||||
|
"cut short": `{"webhooks":[`,
|
||||||
|
"more after it": `{"webhooks":[]} {}`,
|
||||||
|
"null": "null",
|
||||||
|
"no list": "{}",
|
||||||
|
"null list": `{"webhooks":null}`,
|
||||||
|
"list alone": "[]",
|
||||||
|
"empty webhook": `{"webhooks":[{}]}`,
|
||||||
|
"id not hex": record("not-an-id", "3", hookURL),
|
||||||
|
"id too short": record("0011", "3", hookURL),
|
||||||
|
"chat 0": record(unknownID, "0", hookURL),
|
||||||
|
"url of another kind": record(unknownID, "3", "ftp://example.com/"),
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(dir, "webhooks.json"), []byte(contents), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = api.ReadWebhooks(dir)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "webhooks.json") {
|
||||||
|
t.Errorf("ReadWebhooks = %v, want an error naming webhooks.json", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
listed(t, webhookAPI(readWebhooks(t, t.TempDir())), webhooksPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhooksConcurrent: registrations at the same time, among listings
|
||||||
|
// and removals, each get their own webhook, all kept; the same URL
|
||||||
|
// registered at the same time is registered once.
|
||||||
|
func TestWebhooksConcurrent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const n = 20
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
webhooks := readWebhooks(t, dir)
|
||||||
|
answers := make([]*httptest.ResponseRecorder, 2*n)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := range n {
|
||||||
|
wg.Go(func() {
|
||||||
|
answers[i] = post(t, webhookAPI(webhooks), webhooksPath,
|
||||||
|
`{"url":"`+hookURL+"/"+strconv.Itoa(i)+`"}`)
|
||||||
|
})
|
||||||
|
wg.Go(func() {
|
||||||
|
answers[n+i] = post(t, webhookAPI(webhooks), webhooksPath,
|
||||||
|
`{"url":"`+hookURL+`"}`)
|
||||||
|
})
|
||||||
|
wg.Go(func() {
|
||||||
|
srv := webhookAPI(webhooks)
|
||||||
|
request(t, srv, http.MethodGet, webhooksPath, bearer)
|
||||||
|
request(t, srv, http.MethodDelete, webhooksPath+"/"+unknownID, bearer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
created := 0
|
||||||
|
ids := map[string]bool{}
|
||||||
|
|
||||||
|
for _, rec := range answers {
|
||||||
|
var h hook
|
||||||
|
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &h)
|
||||||
|
ids[h.ID] = true
|
||||||
|
|
||||||
|
if rec.Code == http.StatusCreated {
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if created != n+1 || len(ids) != n+1 {
|
||||||
|
t.Errorf("%d registered, %d ids; want %d of each", created, len(ids), n+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := request(t, webhookAPI(readWebhooks(t, dir)),
|
||||||
|
http.MethodGet, webhooksPath, bearer)
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Webhooks []hook `json:"webhooks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if err != nil || len(got.Webhooks) != n+1 {
|
||||||
|
t.Errorf("read again: %d webhooks (%v), want %d", len(got.Webhooks), err, n+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
-31
@@ -8,12 +8,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/api"
|
||||||
"sneak.berlin/go/simplexcalc/internal/calc"
|
"sneak.berlin/go/simplexcalc/internal/calc"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
"sneak.berlin/go/simplexcalc/internal/simplex"
|
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,14 +25,14 @@ import (
|
|||||||
const DisplayName = "calc"
|
const DisplayName = "calc"
|
||||||
|
|
||||||
// Welcome is sent to everyone whose contact request the bot accepts.
|
// Welcome is sent to everyone whose contact request the bot accepts.
|
||||||
const Welcome = "Send me arithmetic, such as 2 + 2 or 5 * 5/2, " +
|
const Welcome = "Send me arithmetic, such as 2 + 2, 5 * 5/2, 2^10 or 7 % 3, " +
|
||||||
"and I will reply with the result."
|
"and I will reply with the result."
|
||||||
|
|
||||||
const (
|
// ChatPort is where the chat client serves its API, on localhost inside
|
||||||
// chatPort is where the chat client serves its API, on localhost
|
// the bot's own container.
|
||||||
// inside the bot's own container.
|
const ChatPort = 5225
|
||||||
chatPort = 5225
|
|
||||||
|
|
||||||
|
const (
|
||||||
// connectTimeout bounds the wait for a freshly started chat client
|
// connectTimeout bounds the wait for a freshly started chat client
|
||||||
// to open its API, which includes creating or migrating the
|
// to open its API, which includes creating or migrating the
|
||||||
// database.
|
// database.
|
||||||
@@ -42,26 +45,49 @@ const (
|
|||||||
// retryInterval paces the connection attempts.
|
// retryInterval paces the connection attempts.
|
||||||
retryInterval = 250 * time.Millisecond
|
retryInterval = 250 * time.Millisecond
|
||||||
|
|
||||||
|
// apiStopTimeout is how long API requests in progress get to finish
|
||||||
|
// when the bot stops, before their connections are closed.
|
||||||
|
apiStopTimeout = 5 * time.Second
|
||||||
|
|
||||||
dataDirMode = 0o700
|
dataDirMode = 0o700
|
||||||
)
|
)
|
||||||
|
|
||||||
var errExited = errors.New("simplex-chat exited")
|
var errExited = errors.New("simplex-chat exited")
|
||||||
|
|
||||||
// Run starts the chat client with its database in dataDir, connects to
|
// Run reads the webhooks kept in cfg.DataDir, starts the chat client with
|
||||||
// it, sets up the bot's address, and answers messages until ctx is
|
// its database there, serving its API on localhost at chatPort, connects
|
||||||
// cancelled — which is a clean stop and returns nil — or until the chat
|
// to it, sets up the bot's address, then answers messages, posts them to
|
||||||
// client or the connection to it fails, which returns the error.
|
// the webhooks and serves the bot's API until ctx is cancelled — which is
|
||||||
func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
|
// a clean stop and returns nil — or until the chat client, the connection
|
||||||
err := os.MkdirAll(dataDir, dataDirMode)
|
// to it or the API's listener fails, which returns the error.
|
||||||
|
func Run(
|
||||||
|
ctx context.Context, log *slog.Logger, cfg *config.Config, chatPort int,
|
||||||
|
) error {
|
||||||
|
err := os.MkdirAll(cfg.DataDir, dataDirMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating data directory: %w", err)
|
return fmt.Errorf("creating data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before the chat client starts, so that a file that cannot be read
|
||||||
|
// stops the bot as configuration that cannot be read does.
|
||||||
|
webhooks, err := api.ReadWebhooks(cfg.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posts the messages the event handler hands it to the webhooks. Its
|
||||||
|
// stop, deferred first, runs last, once the chat client has exited
|
||||||
|
// and can send no more messages; cancelling ctx does not reach it.
|
||||||
|
deliveries := api.StartDeliveries(context.WithoutCancel(ctx), log, webhooks)
|
||||||
|
defer deliveries.Stop()
|
||||||
|
|
||||||
// Cancelling this stops the chat client; the deferred wait makes
|
// Cancelling this stops the chat client; the deferred wait makes
|
||||||
// Run return only once it has exited, whatever path Run takes.
|
// Run return only once it has exited, whatever path Run takes.
|
||||||
cliCtx, stopCLI := context.WithCancel(ctx)
|
// Cancelling ctx does not reach it, so that the API, stopped first,
|
||||||
|
// can finish its requests while the chat client still answers.
|
||||||
|
cliCtx, stopCLI := context.WithCancel(context.WithoutCancel(ctx))
|
||||||
|
|
||||||
cli, err := simplex.StartCLI(cliCtx, log, filepath.Join(dataDir, "simplex"),
|
cli, err := simplex.StartCLI(cliCtx, log, filepath.Join(cfg.DataDir, "simplex"),
|
||||||
DisplayName, chatPort)
|
DisplayName, chatPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
stopCLI()
|
stopCLI()
|
||||||
@@ -74,18 +100,39 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
|
|||||||
<-cli.Done()
|
<-cli.Done()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
client, err := connect(ctx, log, cli)
|
client, err := connect(ctx, log, cli, chatPort, handle(log, deliveries))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = client.Close() }()
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
err = setUp(ctx, log, client)
|
user, err := setUp(ctx, log, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
srv := api.New(api.Params{
|
||||||
|
Log: log,
|
||||||
|
Client: client,
|
||||||
|
UserID: user.UserID,
|
||||||
|
Port: cfg.Port,
|
||||||
|
Token: cfg.APIToken,
|
||||||
|
Webhooks: webhooks,
|
||||||
|
})
|
||||||
|
|
||||||
|
served := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
served <- srv.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Deferred last, so it runs first: requests in progress finish while
|
||||||
|
// the chat client is still there to answer them.
|
||||||
|
defer stopAPI(ctx, log, srv)
|
||||||
|
|
||||||
|
log.Info("starting the API", "port", cfg.Port)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
@@ -93,12 +140,31 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
|
|||||||
return fmt.Errorf("%w: %w", simplex.ErrClosed, client.Err())
|
return fmt.Errorf("%w: %w", simplex.ErrClosed, client.Err())
|
||||||
case <-cli.Done():
|
case <-cli.Done():
|
||||||
return fmt.Errorf("%w: %w", errExited, cli.Err())
|
return fmt.Errorf("%w: %w", errExited, cli.Err())
|
||||||
|
case err := <-served:
|
||||||
|
return fmt.Errorf("serving the API: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// connect waits for the chat client to open its API and connects to it.
|
// stopAPI stops the API server, giving requests in progress up to
|
||||||
|
// apiStopTimeout to finish before closing their connections.
|
||||||
|
func stopAPI(ctx context.Context, log *slog.Logger, srv *http.Server) {
|
||||||
|
// ctx is usually cancelled by now: that is why the bot is stopping.
|
||||||
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), apiStopTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := srv.Shutdown(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("stopping the API", "error", err)
|
||||||
|
|
||||||
|
_ = srv.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect waits for the chat client to open its API on chatPort and
|
||||||
|
// connects to it, passing its events to onEvent.
|
||||||
func connect(
|
func connect(
|
||||||
ctx context.Context, log *slog.Logger, cli *simplex.CLI,
|
ctx context.Context, log *slog.Logger, cli *simplex.CLI, chatPort int,
|
||||||
|
onEvent simplex.EventHandler,
|
||||||
) (*simplex.Client, error) {
|
) (*simplex.Client, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, connectTimeout)
|
ctx, cancel := context.WithTimeout(ctx, connectTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -106,7 +172,7 @@ func connect(
|
|||||||
url := "ws://127.0.0.1:" + strconv.Itoa(chatPort)
|
url := "ws://127.0.0.1:" + strconv.Itoa(chatPort)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
client, err := simplex.Dial(ctx, url, log, handle(log))
|
client, err := simplex.Dial(ctx, url, log, onEvent)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
@@ -125,19 +191,22 @@ func connect(
|
|||||||
// setUp gives the bot a long-term contact address, creating it on the
|
// setUp gives the bot a long-term contact address, creating it on the
|
||||||
// first start, and sets it to accept every contact request and to greet
|
// first start, and sets it to accept every contact request and to greet
|
||||||
// each new contact. The settings are written on every start, so an
|
// each new contact. The settings are written on every start, so an
|
||||||
// address whose settings were changed by hand is put right.
|
// address whose settings were changed by hand is put right. It returns
|
||||||
func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error {
|
// the bot's user profile.
|
||||||
|
func setUp(
|
||||||
|
ctx context.Context, log *slog.Logger, client *simplex.Client,
|
||||||
|
) (simplex.User, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, setupTimeout)
|
ctx, cancel := context.WithTimeout(ctx, setupTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
user, err := client.ActiveUser(ctx)
|
user, err := client.ActiveUser(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading the bot's profile: %w", err)
|
return user, fmt.Errorf("reading the bot's profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
link, ok, err := client.Address(ctx, user.UserID)
|
link, ok, err := client.Address(ctx, user.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading the bot's address: %w", err)
|
return user, fmt.Errorf("reading the bot's address: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -145,7 +214,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error
|
|||||||
|
|
||||||
link, err = client.CreateAddress(ctx, user.UserID)
|
link, err = client.CreateAddress(ctx, user.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating the bot's address: %w", err)
|
return user, fmt.Errorf("creating the bot's address: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +223,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error
|
|||||||
AutoReply: &simplex.MsgContent{Type: "text", Text: Welcome},
|
AutoReply: &simplex.MsgContent{Type: "text", Text: Welcome},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("setting the bot's address to accept everyone: %w", err)
|
return user, fmt.Errorf("setting the bot's address to accept everyone: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("ready",
|
log.Info("ready",
|
||||||
@@ -163,11 +232,12 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error
|
|||||||
"full_address", link.FullLink,
|
"full_address", link.FullLink,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle answers each text message a contact sends.
|
// handle answers each text message a contact sends, and hands every
|
||||||
func handle(log *slog.Logger) simplex.EventHandler {
|
// message a contact sends to deliveries, for the webhooks of its chat.
|
||||||
|
func handle(log *slog.Logger, deliveries *api.Deliveries) simplex.EventHandler {
|
||||||
return func(c *simplex.Client, ev simplex.Event) {
|
return func(c *simplex.Client, ev simplex.Event) {
|
||||||
switch ev.Type {
|
switch ev.Type {
|
||||||
case simplex.TypeNewChatItems:
|
case simplex.TypeNewChatItems:
|
||||||
@@ -181,6 +251,9 @@ func handle(log *slog.Logger) simplex.EventHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range r.ChatItems {
|
for _, item := range r.ChatItems {
|
||||||
|
// Never waits, so no webhook holds up the replies.
|
||||||
|
deliveries.Add(item)
|
||||||
|
|
||||||
msg, ok := item.Message()
|
msg, ok := item.Message()
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -221,10 +294,12 @@ func Reply(text string) string {
|
|||||||
calc.MaxInputLength)
|
calc.MaxInputLength)
|
||||||
case errors.Is(err, calc.ErrDivisionByZero):
|
case errors.Is(err, calc.ErrDivisionByZero):
|
||||||
return "I cannot divide by zero."
|
return "I cannot divide by zero."
|
||||||
case errors.Is(err, calc.ErrTooLarge):
|
case errors.Is(err, calc.ErrOutOfRange):
|
||||||
return "The result is too large for me."
|
return "That needs a number too large or too small for me."
|
||||||
|
case errors.Is(err, calc.ErrNoRealResult):
|
||||||
|
return "A negative number to a fractional power has no real result."
|
||||||
default:
|
default:
|
||||||
return "I only understand arithmetic: numbers, + - * / and " +
|
return "I only understand arithmetic: numbers, + - * /, ^ for a power, " +
|
||||||
"parentheses, such as 5 * 5/2."
|
"% for a remainder, and parentheses, such as 5 * 5/2 or 2^10."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ func TestReply(t *testing.T) {
|
|||||||
for in, want := range map[string]string{
|
for in, want := range map[string]string{
|
||||||
"2 + 2": "4",
|
"2 + 2": "4",
|
||||||
"5 * 5/2": "12.5",
|
"5 * 5/2": "12.5",
|
||||||
|
"2^10": "1024",
|
||||||
|
"7 % 3": "1",
|
||||||
|
"2^1200": "1.7218479456385751e+361",
|
||||||
} {
|
} {
|
||||||
if got := bot.Reply(in); got != want {
|
if got := bot.Reply(in); got != want {
|
||||||
t.Errorf("Reply(%q) = %q, want %q", in, got, want)
|
t.Errorf("Reply(%q) = %q, want %q", in, got, want)
|
||||||
@@ -25,7 +28,9 @@ func TestReply(t *testing.T) {
|
|||||||
for in, want := range map[string]string{
|
for in, want := range map[string]string{
|
||||||
"hello": "I only understand arithmetic",
|
"hello": "I only understand arithmetic",
|
||||||
"1 / 0": "I cannot divide by zero.",
|
"1 / 0": "I cannot divide by zero.",
|
||||||
"1e400": "The result is too large for me.",
|
"1e1300": "That needs a number too large or too small for me.",
|
||||||
|
"1e-1300": "That needs a number too large or too small for me.",
|
||||||
|
"(-8)^0.5": "A negative number to a fractional power has no real",
|
||||||
strings.Repeat("1+", calc.MaxInputLength) + "1": "That is too long for me",
|
strings.Repeat("1+", calc.MaxInputLength) + "1": "That is too long for me",
|
||||||
} {
|
} {
|
||||||
if got := bot.Reply(in); !strings.HasPrefix(got, want) {
|
if got := bot.Reply(in); !strings.HasPrefix(got, want) {
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
package bot_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/bot"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// credential is what the bot's API is configured with here.
|
||||||
|
credential = "a-credential-for-these-tests" //nolint:gosec // G101: invented for tests
|
||||||
|
|
||||||
|
// asked is the file the stand-in chat client creates in the data
|
||||||
|
// directory when it is asked for the contacts.
|
||||||
|
asked = "asked-for-contacts"
|
||||||
|
|
||||||
|
// contactsDelay is how long the stand-in then holds its answer:
|
||||||
|
// long enough for the test to stop the bot meanwhile, and well
|
||||||
|
// within the 5 seconds the API gets to finish its requests.
|
||||||
|
contactsDelay = time.Second
|
||||||
|
|
||||||
|
// sent starts the name of the file the stand-in writes in the data
|
||||||
|
// directory for each message the bot sends, holding the command.
|
||||||
|
sent = "sent-"
|
||||||
|
)
|
||||||
|
|
||||||
|
// twoMessages is the event the stand-in sends once the bot is set up: a
|
||||||
|
// contact sending the bot two messages. It is reduced to the fields the
|
||||||
|
// bot reads.
|
||||||
|
const twoMessages = `{"type":"newChatItems","chatItems":[
|
||||||
|
{"chatInfo":{"type":"direct","contact":{"contactId":3}},
|
||||||
|
"chatItem":{"chatDir":{"type":"directRcv"},
|
||||||
|
"meta":{"itemId":41,"itemTs":"2026-09-29T03:14:34Z"},
|
||||||
|
"content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"2 + 2"}}}},
|
||||||
|
{"chatInfo":{"type":"direct","contact":{"contactId":3}},
|
||||||
|
"chatItem":{"chatDir":{"type":"directRcv"},
|
||||||
|
"meta":{"itemId":42,"itemTs":"2026-09-29T03:14:35Z"},
|
||||||
|
"content":{"type":"rcvMsgContent","msgContent":{"type":"text","text":"3 * 3"}}}}]}`
|
||||||
|
|
||||||
|
// TestMain lets this test binary be the chat client as well: started
|
||||||
|
// under the chat client's name, as standInPath arranges, it is the
|
||||||
|
// stand-in instead of running the tests.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if filepath.Base(os.Args[0]) == simplex.Binary {
|
||||||
|
standIn() // never returns
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// standIn plays the chat client: it serves the WebSocket API on the
|
||||||
|
// port it is given, on localhost, and answers the commands the bot
|
||||||
|
// sends. SIGTERM ends it, as it ends the real client. Unlike the real
|
||||||
|
// client, it also exits when the bot hangs up, so that it never
|
||||||
|
// outlives a test run that was cut short.
|
||||||
|
func standIn() {
|
||||||
|
arg := func(name string) string {
|
||||||
|
return os.Args[slices.Index(os.Args, name)+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(arg("--database"))
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: "127.0.0.1:" + arg("--chat-server-port"),
|
||||||
|
ReadHeaderTimeout: time.Second,
|
||||||
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
answer(w, r, dir)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := srv.ListenAndServe()
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||||
|
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// answer answers the commands on one connection with records reduced to
|
||||||
|
// the fields the bot reads. Asked for the contacts, it first creates the
|
||||||
|
// file asked in dir, then holds its answer for contactsDelay. Once the
|
||||||
|
// bot is set up, it sends twoMessages. Each message the bot sends, it
|
||||||
|
// writes to a file in dir, named sent and the command's id.
|
||||||
|
func answer(w http.ResponseWriter, r *http.Request, dir string) {
|
||||||
|
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
records := map[string]string{
|
||||||
|
"/user": `{"type":"activeUser","user":{"userId":1}}`,
|
||||||
|
"/_show_address": `{"type":"userContactLink","contactLink":{}}`,
|
||||||
|
"/_address_settings": `{"type":"userContactLinkUpdated"}`,
|
||||||
|
"/_contacts": `{"type":"contactsList","contacts":[{"contactId":3,` +
|
||||||
|
`"profile":{"displayName":"tester"},"contactStatus":"active"}]}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
var cmd map[string]string
|
||||||
|
|
||||||
|
err = conn.ReadJSON(&cmd)
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
name, _, _ := strings.Cut(cmd["cmd"], " ")
|
||||||
|
|
||||||
|
if name == "/_send" {
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, sent+cmd["corrId"]),
|
||||||
|
[]byte(cmd["cmd"]), 0o600)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
record, ok := records[name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "/_contacts" {
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, asked), nil, 0o600)
|
||||||
|
|
||||||
|
time.Sleep(contactsDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = conn.WriteJSON(map[string]any{
|
||||||
|
"corrId": cmd["corrId"],
|
||||||
|
"resp": json.RawMessage(record),
|
||||||
|
})
|
||||||
|
|
||||||
|
// The last command of the bot's set-up.
|
||||||
|
if name == "/_address_settings" {
|
||||||
|
_ = conn.WriteJSON(map[string]any{"resp": json.RawMessage(twoMessages)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopDuringRequest: a request that is waiting on the chat client
|
||||||
|
// when the bot is told to stop still gets the chat client's answer,
|
||||||
|
// because the chat client is stopped only once the API has stopped.
|
||||||
|
func TestStopDuringRequest(t *testing.T) {
|
||||||
|
t.Setenv("PATH", standInPath(t))
|
||||||
|
|
||||||
|
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t), APIToken: credential}
|
||||||
|
stop, done := runBot(t, cfg)
|
||||||
|
|
||||||
|
// Stop the bot once the request below is waiting on the chat client.
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
_, err := os.Stat(filepath.Join(cfg.DataDir, asked))
|
||||||
|
if err == nil {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-time.After(10 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
status, body := getChats(t, cfg.Port, done)
|
||||||
|
|
||||||
|
want := `{"chats":[{"id":3,"display_name":"tester","contact_deleted":false}]}` + "\n"
|
||||||
|
if status != http.StatusOK || body != want {
|
||||||
|
t.Errorf("GET /api/v1/chats = %d %q, want 200 %q", status, body, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSlowWebhook: a webhook that takes the bot's POSTs and never answers
|
||||||
|
// them holds up neither the bot's replies nor its stop.
|
||||||
|
func TestSlowWebhook(t *testing.T) {
|
||||||
|
t.Setenv("PATH", standInPath(t))
|
||||||
|
|
||||||
|
posts := make(chan string, 2)
|
||||||
|
receiver := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
posts <- string(body)
|
||||||
|
|
||||||
|
<-r.Context().Done() // until the bot abandons the POST
|
||||||
|
}))
|
||||||
|
t.Cleanup(receiver.Close)
|
||||||
|
|
||||||
|
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t), APIToken: credential}
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(cfg.DataDir, "webhooks.json"),
|
||||||
|
[]byte(`{"webhooks":[{"id":"00112233445566778899aabbccddeeff",`+
|
||||||
|
`"chat_id":3,"url":"`+receiver.URL+`"}]}`), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop, done := runBot(t, cfg)
|
||||||
|
|
||||||
|
// The webhook gets both messages, and holds on to them...
|
||||||
|
got := []string{receive(t, posts, done), receive(t, posts, done)}
|
||||||
|
slices.Sort(got)
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
`{"chat_id":3,"message":{"id":41,"direction":"received",` +
|
||||||
|
`"type":"text","text":"2 + 2","time":"2026-09-29T03:14:34Z"}}`,
|
||||||
|
`{"chat_id":3,"message":{"id":42,"direction":"received",` +
|
||||||
|
`"type":"text","text":"3 * 3","time":"2026-09-29T03:14:35Z"}}`,
|
||||||
|
}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("posted %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...while the bot answers both.
|
||||||
|
replies := []string{
|
||||||
|
`/_send @3 json [{"quotedItemId":41,` +
|
||||||
|
`"msgContent":{"type":"text","text":"4"},"mentions":{}}]`,
|
||||||
|
`/_send @3 json [{"quotedItemId":42,` +
|
||||||
|
`"msgContent":{"type":"text","text":"9"},"mentions":{}}]`,
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for !slices.Equal(sentMessages(t, cfg.DataDir), replies) {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("sent %q, want %q", sentMessages(t, cfg.DataDir), replies)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stopping abandons the POSTs, rather than give the webhook the 10
|
||||||
|
// seconds it has to answer.
|
||||||
|
stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Run did not return while a webhook held a POST")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnreadableWebhooks: a webhooks file that cannot be read stops Run
|
||||||
|
// before it starts the chat client.
|
||||||
|
func TestUnreadableWebhooks(t *testing.T) {
|
||||||
|
// No chat client on PATH: starting one would fail with another error.
|
||||||
|
t.Setenv("PATH", t.TempDir())
|
||||||
|
|
||||||
|
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t)}
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(cfg.DataDir, "webhooks.json"), []byte("{"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = bot.Run(t.Context(), slog.New(slog.DiscardHandler), cfg, freePort(t))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "webhooks.json") {
|
||||||
|
t.Errorf("Run = %v, want an error naming webhooks.json", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// standInPath returns a directory holding this test binary under the
|
||||||
|
// chat client's name. Run starts the chat client from PATH, so with PATH
|
||||||
|
// set to it, Run starts the stand-in.
|
||||||
|
func standInPath(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
bin := t.TempDir()
|
||||||
|
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Symlink(exe, filepath.Join(bin, simplex.Binary))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bin
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBot runs the bot with cfg until stop is called or the test ends,
|
||||||
|
// and fails the test if Run returns an error. done is closed once Run
|
||||||
|
// has returned.
|
||||||
|
func runBot(t *testing.T, cfg *config.Config) (context.CancelFunc, <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Never bot.ChatPort: a real chat client may be listening there.
|
||||||
|
chatPort := freePort(t)
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
var runErr error
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
runErr = bot.Run(ctx, slog.New(slog.DiscardHandler), cfg, chatPort)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
stop()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
if runErr != nil {
|
||||||
|
t.Errorf("Run: %v", runErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return stop, done
|
||||||
|
}
|
||||||
|
|
||||||
|
// receive returns the next of posts. It fails the test if none comes
|
||||||
|
// within 10 seconds, or if Run returns first, which closes done.
|
||||||
|
func receive(t *testing.T, posts <-chan string, done <-chan struct{}) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case p := <-posts:
|
||||||
|
return p
|
||||||
|
case <-done:
|
||||||
|
t.Fatal("Run returned before the webhook got the messages")
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("the webhook did not get the messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentMessages returns the commands of the messages the bot has sent, as
|
||||||
|
// the stand-in wrote them in dir, sorted.
|
||||||
|
func sentMessages(t *testing.T, dir string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmds []string
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !strings.HasPrefix(entry.Name(), sent) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // G304: the test's own file.
|
||||||
|
cmd, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmds = append(cmds, string(cmd))
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(cmds)
|
||||||
|
|
||||||
|
return cmds
|
||||||
|
}
|
||||||
|
|
||||||
|
// freePort returns a TCP port that nothing listens on at the moment.
|
||||||
|
func freePort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
l, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", ":0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = l.Close() }()
|
||||||
|
|
||||||
|
return int(netip.MustParseAddrPort(l.Addr().String()).Port())
|
||||||
|
}
|
||||||
|
|
||||||
|
// getChats asks the bot's API for the chats, trying again while the API
|
||||||
|
// is not listening yet, and returns the answer's status and body. It
|
||||||
|
// fails the test if Run returns first, which closes done.
|
||||||
|
func getChats(t *testing.T, port int, done <-chan struct{}) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
url := "http://127.0.0.1:" + strconv.Itoa(port) + "/api/v1/chats"
|
||||||
|
|
||||||
|
for {
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+credential)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.StatusCode, string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
t.Fatal("Run returned before the API answered")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+420
-87
@@ -1,29 +1,45 @@
|
|||||||
// Package calc evaluates the arithmetic people send the bot: decimal
|
// Package calc evaluates the arithmetic people send the bot: decimal
|
||||||
// numbers, + - * /, unary minus and parentheses.
|
// numbers, + - * / % ^, signs and parentheses.
|
||||||
//
|
//
|
||||||
// The expression is parsed by go/parser and computed by go/constant,
|
// The expression is read by a small parser of its own, because Go's
|
||||||
// which does exact rational arithmetic: 5 * 5/2 is exactly 12.5, and
|
// grammar has no power operator (^ is XOR there), and computed by
|
||||||
// 0.1 + 0.2 is exactly 0.3, so a result carries no binary floating
|
// go/constant, which does exact rational arithmetic: 5 * 5/2 is exactly
|
||||||
// point noise until the moment it is formatted.
|
// 12.5, and 0.1 + 0.2 is exactly 0.3, so a result carries no binary
|
||||||
|
// floating point noise until the moment it is formatted. A power is the
|
||||||
|
// exception: one with a fractional exponent, or whose result go/constant
|
||||||
|
// cannot hold exactly, is computed in float64.
|
||||||
package calc
|
package calc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"go/ast"
|
|
||||||
"go/constant"
|
"go/constant"
|
||||||
"go/parser"
|
|
||||||
"go/token"
|
"go/token"
|
||||||
"math"
|
"math"
|
||||||
|
"math/big"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaxInputLength caps an expression, in bytes, so a message cannot make
|
// MaxInputLength caps an expression, in bytes. With maxExactExponent,
|
||||||
// the bot do unbounded work. Every operation's cost grows with the size
|
// which caps a power computed exactly, and bitLimit, which caps every
|
||||||
// of its operands, and the operands can only grow with the input.
|
// number, it keeps a message from making the bot do unbounded work.
|
||||||
const MaxInputLength = 256
|
const MaxInputLength = 256
|
||||||
|
|
||||||
|
// bitLimit caps the numerator and denominator of every number: see
|
||||||
|
// exact.
|
||||||
|
const bitLimit = 4096
|
||||||
|
|
||||||
|
// maxExactExponent is the largest exponent, either way, of a power
|
||||||
|
// computed exactly. Past it, x^n has a numerator or denominator of more
|
||||||
|
// than 4096 bits, which go/constant holds only rounded, unless x is 0 or
|
||||||
|
// 1, and float64 computes those exactly.
|
||||||
|
const maxExactExponent = 4096
|
||||||
|
|
||||||
|
// smallestNormal is the smallest positive normal double, about 2.2e-308.
|
||||||
|
// Below it a double keeps fewer digits, down to one.
|
||||||
|
const smallestNormal = 0x1p-1022
|
||||||
|
|
||||||
// Results of magnitude plainUpper or more are written in exponent form
|
// Results of magnitude plainUpper or more are written in exponent form
|
||||||
// (1e+21 rather than twenty-two digits), and so are fractions smaller
|
// (1e+21 rather than twenty-two digits), and so are fractions smaller
|
||||||
// than plainLower (1e-07 rather than 0.0000001).
|
// than plainLower (1e-07 rather than 0.0000001).
|
||||||
@@ -32,150 +48,467 @@ const (
|
|||||||
plainLower = 1e-6
|
plainLower = 1e-6
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A result past the normal range of a double is written to
|
||||||
|
// significantDigits significant digits, the most the shortest form of a
|
||||||
|
// double takes. It is rounded to them from a float of floatPrecision
|
||||||
|
// bits, the bits a numerator or denominator can hold and 64 more for the
|
||||||
|
// digits, so that the float rounds as the exact result would. The square
|
||||||
|
// roots of a power's base past that range are taken in such a float too:
|
||||||
|
// see nonNegativePower.
|
||||||
|
const (
|
||||||
|
significantDigits = 17
|
||||||
|
floatPrecision = bitLimit + 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// The precedence of the binary operators: the higher, the tighter the
|
||||||
|
// operator binds.
|
||||||
|
const (
|
||||||
|
sumPrecedence = iota + 1
|
||||||
|
productPrecedence
|
||||||
|
powerPrecedence
|
||||||
|
)
|
||||||
|
|
||||||
// Errors returned by Evaluate. The bot turns each into a reply.
|
// Errors returned by Evaluate. The bot turns each into a reply.
|
||||||
var (
|
var (
|
||||||
ErrTooLong = errors.New("expression too long")
|
ErrTooLong = errors.New("expression too long")
|
||||||
ErrNotArithmetic = errors.New("not an arithmetic expression")
|
ErrNotArithmetic = errors.New("not an arithmetic expression")
|
||||||
ErrDivisionByZero = errors.New("division by zero")
|
ErrDivisionByZero = errors.New("division by zero")
|
||||||
ErrTooLarge = errors.New("result too large")
|
ErrOutOfRange = errors.New("number too large or too small")
|
||||||
|
ErrNoRealResult = errors.New("no real result")
|
||||||
)
|
)
|
||||||
|
|
||||||
// decimalLiteral is the only number syntax accepted. Go's own literal
|
// decimal is the only number syntax accepted. Go's own literal syntax is
|
||||||
// syntax is wider, and parts of it are traps for someone typing
|
// wider, and parts of it are traps for someone typing arithmetic: 010 is
|
||||||
// arithmetic: 010 is octal 8, and 0x10, 1_000 and 1i are not what a
|
// octal 8, and 0x10, 1_000 and 1i are not what a calculator user means
|
||||||
// calculator user means by a number.
|
// by a number. Here the x, _ or i matches no token and is refused.
|
||||||
var decimalLiteral = regexp.MustCompile(
|
const decimal = `([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?`
|
||||||
`^([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$`,
|
|
||||||
|
var (
|
||||||
|
// nextToken matches the token at the start of the input, after any
|
||||||
|
// whitespace: an operator, a parenthesis or a number. ** comes
|
||||||
|
// before * so that it is read as one token.
|
||||||
|
nextToken = regexp.MustCompile(`^\s*(\*\*|[-+*/%^()]|` + decimal + `)`)
|
||||||
|
|
||||||
|
// decimalLiteral matches a token that is a number.
|
||||||
|
decimalLiteral = regexp.MustCompile(`^` + decimal + `$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Evaluate computes an arithmetic expression and returns its result as
|
// Evaluate computes an arithmetic expression and returns its result as
|
||||||
// text: whole numbers without a decimal point, fractions in the
|
// text, written as format describes.
|
||||||
// shortest form that reads back as the same float64.
|
|
||||||
func Evaluate(input string) (string, error) {
|
func Evaluate(input string) (string, error) {
|
||||||
s := strings.TrimSpace(input)
|
s := strings.TrimSpace(input)
|
||||||
if len(s) > MaxInputLength {
|
if len(s) > MaxInputLength {
|
||||||
return "", ErrTooLong
|
return "", ErrTooLong
|
||||||
}
|
}
|
||||||
|
|
||||||
if s == "" {
|
tokens, err := tokenize(s)
|
||||||
return "", ErrNotArithmetic
|
|
||||||
}
|
|
||||||
|
|
||||||
expr, err := parser.ParseExpr(s)
|
|
||||||
if err != nil {
|
|
||||||
return "", ErrNotArithmetic
|
|
||||||
}
|
|
||||||
|
|
||||||
v, err := eval(expr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return format(v)
|
p := parser{tokens: tokens}
|
||||||
|
|
||||||
|
v, err := p.expr(sumPrecedence)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// eval walks the syntax tree, allowing only the node types and
|
if p.next() != "" {
|
||||||
// operators of arithmetic. Anything else — identifiers, calls, strings,
|
return "", ErrNotArithmetic
|
||||||
// shifts, comparisons — is refused, not evaluated.
|
}
|
||||||
func eval(e ast.Expr) (constant.Value, error) {
|
|
||||||
switch n := e.(type) {
|
return format(v), nil
|
||||||
case *ast.BasicLit:
|
}
|
||||||
return literal(n)
|
|
||||||
case *ast.ParenExpr:
|
// tokenize splits an expression into operators, parentheses and
|
||||||
return eval(n.X)
|
// numbers, and refuses anything else. ** is returned as ^.
|
||||||
case *ast.UnaryExpr:
|
func tokenize(s string) ([]string, error) {
|
||||||
if n.Op != token.ADD && n.Op != token.SUB {
|
var tokens []string
|
||||||
|
|
||||||
|
for strings.TrimSpace(s) != "" {
|
||||||
|
m := nextToken.FindStringSubmatch(s)
|
||||||
|
if m == nil {
|
||||||
return nil, ErrNotArithmetic
|
return nil, ErrNotArithmetic
|
||||||
}
|
}
|
||||||
|
|
||||||
x, err := eval(n.X)
|
tok := m[1]
|
||||||
|
if tok == "**" {
|
||||||
|
tok = "^"
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens = append(tokens, tok)
|
||||||
|
s = s[len(m[0]):]
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parser computes an expression as it reads it, by precedence climbing:
|
||||||
|
// expr reads operands joined by operators of at least a given
|
||||||
|
// precedence, and hands the right operand of each to a deeper call that
|
||||||
|
// takes only the operators that bind tighter, so those are applied
|
||||||
|
// first.
|
||||||
|
type parser struct {
|
||||||
|
tokens []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// next removes and returns the next token, or "" at the end.
|
||||||
|
func (p *parser) next() string {
|
||||||
|
tok := p.peek()
|
||||||
|
if tok != "" {
|
||||||
|
p.tokens = p.tokens[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
|
||||||
|
// peek returns the next token, or "" at the end, and leaves it unread.
|
||||||
|
func (p *parser) peek() string {
|
||||||
|
if len(p.tokens) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.tokens[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// expr reads and computes an expression whose binary operators all have
|
||||||
|
// at least minPrecedence. Operators of equal precedence group to the
|
||||||
|
// left, 8/2/2 is (8/2)/2, except ^, which groups to the right: 2^3^2 is
|
||||||
|
// 2^(3^2).
|
||||||
|
func (p *parser) expr(minPrecedence int) (constant.Value, error) {
|
||||||
|
x, err := p.operand()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return constant.UnaryOp(n.Op, x, 0), nil
|
for {
|
||||||
case *ast.BinaryExpr:
|
op := p.peek()
|
||||||
return binary(n)
|
|
||||||
|
prec := precedence(op)
|
||||||
|
if prec < minPrecedence {
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.next()
|
||||||
|
|
||||||
|
rightPrecedence := prec + 1
|
||||||
|
if op == "^" {
|
||||||
|
rightPrecedence = prec
|
||||||
|
}
|
||||||
|
|
||||||
|
y, err := p.expr(rightPrecedence)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
x, err = apply(x, op, y)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// operand reads a number, an expression in parentheses, or a sign and
|
||||||
|
// its operand. A sign binds more loosely than a power that follows it,
|
||||||
|
// so -2^2 is -(2^2), and 2^-1 is 2^(-1).
|
||||||
|
func (p *parser) operand() (constant.Value, error) {
|
||||||
|
switch tok := p.next(); tok {
|
||||||
|
case "+", "-":
|
||||||
|
x, err := p.expr(powerPrecedence)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tok == "-" {
|
||||||
|
x = constant.UnaryOp(token.SUB, x, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return x, nil
|
||||||
|
case "(":
|
||||||
|
x, err := p.expr(sumPrecedence)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.next() != ")" {
|
||||||
|
return nil, ErrNotArithmetic
|
||||||
|
}
|
||||||
|
|
||||||
|
return x, nil
|
||||||
default:
|
default:
|
||||||
return nil, ErrNotArithmetic
|
return number(tok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func binary(n *ast.BinaryExpr) (constant.Value, error) {
|
// precedence returns the precedence of a binary operator, and 0 for any
|
||||||
switch n.Op { //nolint:exhaustive // every other operator is refused.
|
// other token, which ends an expression.
|
||||||
case token.ADD, token.SUB, token.MUL, token.QUO:
|
func precedence(op string) int {
|
||||||
|
switch op {
|
||||||
|
case "+", "-":
|
||||||
|
return sumPrecedence
|
||||||
|
case "*", "/", "%":
|
||||||
|
return productPrecedence
|
||||||
|
case "^":
|
||||||
|
return powerPrecedence
|
||||||
default:
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func number(tok string) (constant.Value, error) {
|
||||||
|
if !decimalLiteral.MatchString(tok) {
|
||||||
return nil, ErrNotArithmetic
|
return nil, ErrNotArithmetic
|
||||||
}
|
}
|
||||||
|
|
||||||
x, err := eval(n.X)
|
// Read as FLOAT, which makes every literal decimal and a fraction
|
||||||
|
// (see exact): as INT, a leading zero would make it octal.
|
||||||
|
v := constant.MakeFromLiteral(tok, token.FLOAT, 0)
|
||||||
|
|
||||||
|
// A literal such as 1e1300 or 1e-1233 is past bitLimit: see exact.
|
||||||
|
if !exact(v) {
|
||||||
|
return nil, ErrOutOfRange
|
||||||
|
}
|
||||||
|
|
||||||
|
// One too small even to be held rounded, such as 1e-999999999, is
|
||||||
|
// read as 0.
|
||||||
|
mantissa, _, _ := strings.Cut(strings.ToLower(tok), "e")
|
||||||
|
if constant.Sign(v) == 0 && strings.ContainsAny(mantissa, "123456789") {
|
||||||
|
return nil, ErrOutOfRange
|
||||||
|
}
|
||||||
|
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply computes x op y.
|
||||||
|
func apply(x constant.Value, op string, y constant.Value) (constant.Value, error) {
|
||||||
|
var (
|
||||||
|
v constant.Value
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch op {
|
||||||
|
case "+":
|
||||||
|
v = constant.BinaryOp(x, token.ADD, y)
|
||||||
|
case "-":
|
||||||
|
v = constant.BinaryOp(x, token.SUB, y)
|
||||||
|
case "*":
|
||||||
|
v = constant.BinaryOp(x, token.MUL, y)
|
||||||
|
case "/":
|
||||||
|
v, err = divide(x, y)
|
||||||
|
case "%":
|
||||||
|
v, err = modulo(x, y)
|
||||||
|
case "^":
|
||||||
|
v, err = power(x, y)
|
||||||
|
default:
|
||||||
|
err = ErrNotArithmetic
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
y, err := eval(n.Y)
|
if !exact(v) {
|
||||||
if err != nil {
|
return nil, ErrOutOfRange
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func divide(x, y constant.Value) (constant.Value, error) {
|
||||||
// constant.BinaryOp panics on a zero divisor.
|
// constant.BinaryOp panics on a zero divisor.
|
||||||
if n.Op == token.QUO && constant.Sign(y) == 0 {
|
if constant.Sign(y) == 0 {
|
||||||
return nil, ErrDivisionByZero
|
return nil, ErrDivisionByZero
|
||||||
}
|
}
|
||||||
|
|
||||||
// token.QUO divides exactly, integers included: 25/2 is 12.5.
|
// token.QUO divides exactly, integers included: 25/2 is 12.5.
|
||||||
v := constant.BinaryOp(x, n.Op, y)
|
return constant.BinaryOp(x, token.QUO, y), nil
|
||||||
|
}
|
||||||
|
|
||||||
// go/constant represents an overflow to infinity as Unknown.
|
// modulo computes x % y, whose result takes the sign of y, as in Python:
|
||||||
if v.Kind() == constant.Unknown {
|
// -7 % 3 is 2 and 7 % -3 is -2. It is exact for decimals too: 7.5 % 2
|
||||||
return nil, ErrTooLarge
|
// is 1.5.
|
||||||
|
func modulo(x, y constant.Value) (constant.Value, error) {
|
||||||
|
q, err := divide(x, y)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fractional part of a rounded quotient, and so the remainder,
|
||||||
|
// would be wrong.
|
||||||
|
if !exact(q) {
|
||||||
|
return nil, ErrOutOfRange
|
||||||
|
}
|
||||||
|
|
||||||
|
// x % y is y times the fractional part of x/y, which is at least 0
|
||||||
|
// and less than 1, so the result has the sign of y. It is not
|
||||||
|
// computed as x minus y times the whole part of x/y: that product
|
||||||
|
// can be too large to hold exactly when the remainder is not.
|
||||||
|
//
|
||||||
|
// For x/y = n/d the fractional part is (n mod d)/d, exact because d
|
||||||
|
// is. token.REM truncates, leaving the sign of n; adding d brings a
|
||||||
|
// negative one into range.
|
||||||
|
n, d := constant.Num(q), constant.Denom(q)
|
||||||
|
|
||||||
|
m := constant.BinaryOp(n, token.REM, d)
|
||||||
|
if constant.Sign(m) < 0 {
|
||||||
|
m = constant.BinaryOp(m, token.ADD, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
return constant.BinaryOp(y, token.MUL, constant.BinaryOp(m, token.QUO, d)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// power computes x^y. A negative x needs a whole y, and its sign is
|
||||||
|
// applied here: math.Pow would take it from the parity of y's float64
|
||||||
|
// value, and every float64 from 2^53 up is even.
|
||||||
|
func power(x, y constant.Value) (constant.Value, error) {
|
||||||
|
// n is y if y is a whole number, and Unknown otherwise.
|
||||||
|
n := constant.ToInt(y)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case constant.Sign(x) == 0 && constant.Sign(y) < 0:
|
||||||
|
return nil, ErrDivisionByZero
|
||||||
|
case constant.Sign(x) >= 0:
|
||||||
|
return nonNegativePower(x, y, n)
|
||||||
|
case n.Kind() != constant.Int:
|
||||||
|
return nil, ErrNoRealResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// x is negative and n whole: x^n is (-x)^n, negated if n is odd.
|
||||||
|
v, err := nonNegativePower(constant.UnaryOp(token.SUB, x, 0), y, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
odd := constant.BinaryOp(n, token.AND, constant.MakeInt64(1))
|
||||||
|
if constant.Sign(odd) != 0 {
|
||||||
|
v = constant.UnaryOp(token.SUB, v, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func literal(n *ast.BasicLit) (constant.Value, error) {
|
// nonNegativePower computes x^y for x of at least zero, and y not below
|
||||||
if n.Kind != token.INT && n.Kind != token.FLOAT {
|
// zero if x is zero: exactly if y is a whole number n and go/constant
|
||||||
return nil, ErrNotArithmetic
|
// holds the result exactly, otherwise in float64.
|
||||||
}
|
func nonNegativePower(x, y, n constant.Value) (constant.Value, error) {
|
||||||
|
e, ok := constant.Int64Val(n)
|
||||||
if !decimalLiteral.MatchString(n.Value) {
|
if ok && -maxExactExponent <= e && e <= maxExactExponent {
|
||||||
return nil, ErrNotArithmetic
|
v := exactPower(x, e)
|
||||||
}
|
if exact(v) {
|
||||||
|
|
||||||
// Read as FLOAT whatever the token says, which makes every literal
|
|
||||||
// decimal: as INT, a leading zero would make it octal.
|
|
||||||
v := constant.MakeFromLiteral(n.Value, token.FLOAT, 0)
|
|
||||||
|
|
||||||
// The syntax was checked above, so Unknown here means the exponent
|
|
||||||
// overflowed.
|
|
||||||
if v.Kind() == constant.Unknown {
|
|
||||||
return nil, ErrTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// y is above zero here if x is zero.
|
||||||
|
if constant.Sign(x) == 0 {
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
xf, _ := constant.Float64Val(x)
|
||||||
|
yf, _ := constant.Float64Val(y)
|
||||||
|
|
||||||
|
// x^y is (√x)^(2y). An x outside the normal range of a double, such
|
||||||
|
// as 2^1200, would lose digits as a double, or all of them, so square
|
||||||
|
// roots taken from its exact value bring it into that range first. As
|
||||||
|
// x is between 2^-4096 and 2^4096 (see exact), three at most are
|
||||||
|
// needed.
|
||||||
|
r, _ := constant.Val(x).(*big.Rat)
|
||||||
|
root := new(big.Float).SetPrec(floatPrecision).SetRat(r)
|
||||||
|
|
||||||
|
for !normal(xf) {
|
||||||
|
root.Sqrt(root)
|
||||||
|
xf, _ = root.Float64()
|
||||||
|
yf *= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// x^y is not zero. If it is not a normal double, it has lost digits,
|
||||||
|
// or all of them.
|
||||||
|
f := math.Pow(xf, yf)
|
||||||
|
if !normal(f) {
|
||||||
|
return nil, ErrOutOfRange
|
||||||
|
}
|
||||||
|
|
||||||
|
return constant.MakeFloat64(f), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// exactPower computes x^e by repeated squaring. x is not zero if e is
|
||||||
|
// negative. It starts from 1 as a fraction, a Float to go/constant, so
|
||||||
|
// that x^0 is a fraction like every other number (see exact). Each
|
||||||
|
// step's numbers stay small: go/constant holds one whose numerator or
|
||||||
|
// denominator reaches 4096 bits as a 512-bit float.
|
||||||
|
func exactPower(x constant.Value, e int64) constant.Value {
|
||||||
|
one := constant.MakeFloat64(1)
|
||||||
|
result := one
|
||||||
|
|
||||||
|
for n := max(e, -e); n > 0; n >>= 1 {
|
||||||
|
if n&1 == 1 {
|
||||||
|
result = constant.BinaryOp(result, token.MUL, x)
|
||||||
|
}
|
||||||
|
|
||||||
|
x = constant.BinaryOp(x, token.MUL, x)
|
||||||
|
}
|
||||||
|
|
||||||
|
if e < 0 {
|
||||||
|
result = constant.BinaryOp(one, token.QUO, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// exact reports whether v is a fraction whose numerator and denominator
|
||||||
|
// are both below bitLimit bits, as every number here must be, so that
|
||||||
|
// each step of arithmetic stays small. go/constant never rounds an
|
||||||
|
// integer, however large, so every number is made a fraction: literals
|
||||||
|
// are read as FLOAT, and a power starts from the fraction 1. It rounds
|
||||||
|
// a fraction that grows past the limit, to a 512-bit float and past
|
||||||
|
// that float's range to Unknown, but not one it reads from a literal,
|
||||||
|
// such as 1e-1233, so the limit is checked here.
|
||||||
|
//
|
||||||
|
// A number that is not exact is refused wherever it appears: a sum can
|
||||||
|
// lose the answer entirely (7^1000*7^1000 + 5 - 7^1000*7^1000 would be
|
||||||
|
// 0), and a remainder, or whether an exponent is whole or odd, cannot be
|
||||||
|
// read from one.
|
||||||
|
func exact(v constant.Value) bool {
|
||||||
|
r, ok := constant.Val(v).(*big.Rat)
|
||||||
|
|
||||||
|
return ok && r.Num().BitLen() < bitLimit && r.Denom().BitLen() < bitLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// normal reports whether f is a normal double, finite and at least
|
||||||
|
// smallestNormal in magnitude: a number other than zero keeps all of a
|
||||||
|
// double's digits only as one.
|
||||||
|
func normal(f float64) bool {
|
||||||
|
abs := math.Abs(f)
|
||||||
|
|
||||||
|
return abs >= smallestNormal && abs <= math.MaxFloat64
|
||||||
|
}
|
||||||
|
|
||||||
// format writes a result for a person to read. A whole number of
|
// format writes a result for a person to read. A whole number of
|
||||||
// ordinary size is written exactly, digit for digit; anything else goes
|
// ordinary size is written exactly, digit for digit. Any other result in
|
||||||
// through float64, whose shortest round-trip form is free of the noise
|
// the normal range of a double goes through float64, whose shortest
|
||||||
// (0.30000000000000004) that printing a binary fraction to a fixed
|
// round-trip form is free of the noise (0.30000000000000004) that
|
||||||
// precision produces.
|
// printing a binary fraction to a fixed precision produces. Past that
|
||||||
func format(v constant.Value) (string, error) {
|
// range a double keeps fewer digits, or none (2^-1074 would be written
|
||||||
|
// 5e-324, and 2^1024 is infinite), so such a result is written from its
|
||||||
|
// exact value, to significantDigits.
|
||||||
|
func format(v constant.Value) string {
|
||||||
f, _ := constant.Float64Val(v)
|
f, _ := constant.Float64Val(v)
|
||||||
if math.IsInf(f, 0) || math.IsNaN(f) {
|
if constant.Sign(v) != 0 && !normal(f) {
|
||||||
return "", ErrTooLarge
|
// Every number here is exact: see exact.
|
||||||
|
r, _ := constant.Val(v).(*big.Rat)
|
||||||
|
|
||||||
|
return new(big.Float).SetPrec(floatPrecision).SetRat(r).Text('g', significantDigits)
|
||||||
}
|
}
|
||||||
|
|
||||||
abs := math.Abs(f)
|
abs := math.Abs(f)
|
||||||
|
|
||||||
if i := constant.ToInt(v); i.Kind() == constant.Int && abs < plainUpper {
|
if i := constant.ToInt(v); i.Kind() == constant.Int && abs < plainUpper {
|
||||||
return i.ExactString(), nil
|
return i.ExactString()
|
||||||
}
|
}
|
||||||
|
|
||||||
if abs >= plainUpper || abs < plainLower {
|
if abs >= plainUpper || abs < plainLower {
|
||||||
return strconv.FormatFloat(f, 'g', -1, 64), nil
|
return strconv.FormatFloat(f, 'g', -1, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
return strconv.FormatFloat(f, 'f', -1, 64), nil
|
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||||
}
|
}
|
||||||
|
|||||||
+277
-9
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/simplexcalc/internal/calc"
|
"sneak.berlin/go/simplexcalc/internal/calc"
|
||||||
)
|
)
|
||||||
@@ -13,7 +14,7 @@ import (
|
|||||||
func TestEvaluate(t *testing.T) {
|
func TestEvaluate(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cases := map[string]string{
|
expectResults(t, map[string]string{
|
||||||
// The specification's own examples.
|
// The specification's own examples.
|
||||||
"2 + 2": "4",
|
"2 + 2": "4",
|
||||||
"5 * 5/2": "12.5",
|
"5 * 5/2": "12.5",
|
||||||
@@ -51,8 +52,139 @@ func TestEvaluate(t *testing.T) {
|
|||||||
"1234567.5": "1234567.5",
|
"1234567.5": "1234567.5",
|
||||||
"-1 / 4": "-0.25",
|
"-1 / 4": "-0.25",
|
||||||
"1e300 * 1e8": "1e+308",
|
"1e300 * 1e8": "1e+308",
|
||||||
|
// Past the normal range of a double, written from the exact value
|
||||||
|
// to 17 significant digits, trailing zeros dropped.
|
||||||
|
"1e400": "1e+400",
|
||||||
|
"1e300 * 1e300": "1e+600",
|
||||||
|
"1 / 2e-400": "5e+399",
|
||||||
|
"-1e-310": "-1e-310",
|
||||||
|
"1 / 3e400": "3.3333333333333333e-401",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEvaluatePowers: ^ and ** are one operator, binding tighter than
|
||||||
|
// * / % and a sign on its left, and grouping to the right.
|
||||||
|
func TestEvaluatePowers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expectResults(t, map[string]string{
|
||||||
|
"2^3": "8",
|
||||||
|
"2**3": "8",
|
||||||
|
"2 ** 3 ^ 2": "512",
|
||||||
|
"2^3^2": "512",
|
||||||
|
"(2^3)^2": "64",
|
||||||
|
"-2^2": "-4",
|
||||||
|
"(-2)^2": "4",
|
||||||
|
"(-2)^3": "-8",
|
||||||
|
"(-2)^-3": "-0.125",
|
||||||
|
"2^-1": "0.5",
|
||||||
|
"2**-1": "0.5",
|
||||||
|
"-2^-2": "-0.25",
|
||||||
|
"2^-3^2": "0.001953125",
|
||||||
|
"2 * 3^2": "18",
|
||||||
|
"3^2 * 2": "18",
|
||||||
|
"2^3 / 2^2": "2",
|
||||||
|
"1 + 2^3 - 3^2": "0",
|
||||||
|
"010^2": "100",
|
||||||
|
"0.1^2": "0.01",
|
||||||
|
"2^100 - 2^100 + 1": "1",
|
||||||
|
"2^64": "18446744073709551616",
|
||||||
|
"2^100": "1.2676506002282294e+30",
|
||||||
|
"0^0": "1",
|
||||||
|
"0^3": "0",
|
||||||
|
"1.5^2": "2.25",
|
||||||
|
"2^0.5": "1.4142135623730951",
|
||||||
|
"-2^0.5": "-1.4142135623730951",
|
||||||
|
"4^0.5": "2",
|
||||||
|
"0^0.5": "0",
|
||||||
|
"2^1023": "8.98846567431158e+307",
|
||||||
|
"2^-1022": "2.2250738585072014e-308",
|
||||||
|
// Past 2^53 a float64 cannot tell odd from even.
|
||||||
|
"(-1)^(2^53 + 1)": "-1",
|
||||||
|
"(-1)^(10^30)": "1",
|
||||||
|
"(-1)^-9223372036854775808": "1",
|
||||||
|
// Whole powers beyond the range of a double, held exactly.
|
||||||
|
"2^-1400 * 2^1365 * 2^35": "1",
|
||||||
|
"0.3^900 * 10^470": "0.25652473503365386",
|
||||||
|
"2^1500 / 2^1000": "3.273390607896142e+150",
|
||||||
|
"(2^1200)/(2^1199)": "2",
|
||||||
|
"2^1200 % 7": "1",
|
||||||
|
// Results past the range of a double, written to 17 significant
|
||||||
|
// digits, up to the largest power of 2 under the 4096-bit limit.
|
||||||
|
"2^1200": "1.7218479456385751e+361",
|
||||||
|
"2**1200": "1.7218479456385751e+361",
|
||||||
|
"2^1024": "1.7976931348623159e+308",
|
||||||
|
"10^400": "1e+400",
|
||||||
|
"2^-1074": "4.9406564584124654e-324",
|
||||||
|
"2^-1200": "5.8077137562175032e-362",
|
||||||
|
"2^-1400": "3.6141491434385841e-422",
|
||||||
|
"0.5^1100": "7.3621518290228627e-332",
|
||||||
|
"1.5^2000": "1.5223626185737825e+352",
|
||||||
|
"(1/3)^-2000": "1.7478712517226516e+954",
|
||||||
|
"2^1200 - 2^1199": "8.6092397281928753e+360",
|
||||||
|
"-2^1201": "-3.4436958912771501e+361",
|
||||||
|
"(-2)^1201": "-3.4436958912771501e+361",
|
||||||
|
"2^2000 * 2^2000": "1.3182040934309431e+1204",
|
||||||
|
"10^1232": "1e+1232",
|
||||||
|
"2^4094": "2.6109722035328813e+1232",
|
||||||
|
// A power computed in float64 carries its rounding into the exact
|
||||||
|
// arithmetic after it, past the range of a double as within it.
|
||||||
|
"2^0.5 * 1e400": "1.4142135623730951e+400",
|
||||||
|
// A fractional power of an exact number outside the range of a
|
||||||
|
// double, taken from its exact value, up to the edges of that
|
||||||
|
// range.
|
||||||
|
"(2^1200)^0.5": "4.149515568880993e+180",
|
||||||
|
"(2^1024)^0.5": "1.3407807929942597e+154",
|
||||||
|
"(2^-1200)^0.5": "2.409919865102884e-181",
|
||||||
|
"1e400^0.5": "1e+200",
|
||||||
|
"1e-400^0.5": "1e-200",
|
||||||
|
"1e-310^0.5": "1e-155",
|
||||||
|
"(2^1200)^-0.5": "2.409919865102884e-181",
|
||||||
|
"(2^1200)^0.5 / 2^600": "1",
|
||||||
|
"1e400^-0.001": "0.39810717055349726",
|
||||||
|
"1e-400^0.001": "0.39810717055349726",
|
||||||
|
"(2^2047)^0.5": "1.2711610061536464e+308",
|
||||||
|
"(2^-2044)^0.5": "2.2250738585072014e-308",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateModulo: % sits with * and /, left to right, and its result
|
||||||
|
// takes the sign of the divisor.
|
||||||
|
func TestEvaluateModulo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expectResults(t, map[string]string{
|
||||||
|
"7 % 3": "1",
|
||||||
|
"-7 % 3": "2",
|
||||||
|
"7 % -3": "-2",
|
||||||
|
"-7 % -3": "-1",
|
||||||
|
"6 % 3": "0",
|
||||||
|
"-6 % 3": "0",
|
||||||
|
"7.5 % 2": "1.5",
|
||||||
|
"0.3 % 0.1": "0",
|
||||||
|
"-0.3 % 0.2": "0.1",
|
||||||
|
"10 % 4 * 3": "6",
|
||||||
|
"2 * 7 % 4": "2",
|
||||||
|
"1 + 7 % 3": "2",
|
||||||
|
"2^10 % 7": "2",
|
||||||
|
"10^400 % 7": "4",
|
||||||
|
"1e-30 % 1": "1e-30",
|
||||||
|
"-1e-30 % 1": "1",
|
||||||
|
"10 / 8 % 1": "0.25",
|
||||||
|
"(7 % 3)^2": "1",
|
||||||
|
"7 % (3 ^ 2)": "7",
|
||||||
|
// Both operands and their quotient are held exactly, but y times
|
||||||
|
// the whole part of x/y is too large to be.
|
||||||
|
"(5^860*3^630/7) % (5^860/2^998/2^998)": "0.5179219763783696",
|
||||||
|
// A whole number made from x^0, just below the 4096-bit limit.
|
||||||
|
"(3^0 + 3^0 + 3^0)^2583 % 10": "7",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// expectResults checks that each expression evaluates to its result.
|
||||||
|
func expectResults(t *testing.T, cases map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
t.Run(in, func(t *testing.T) {
|
t.Run(in, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -70,11 +202,11 @@ func TestEvaluate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestEvaluateRefuses covers what must be answered with an error rather
|
// TestEvaluateRefuses covers what must be answered with an error rather
|
||||||
// than a number, and never with a panic.
|
// than a number.
|
||||||
func TestEvaluateRefuses(t *testing.T) {
|
func TestEvaluateRefuses(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cases := map[string]error{
|
expectErrors(t, map[string]error{
|
||||||
"": calc.ErrNotArithmetic,
|
"": calc.ErrNotArithmetic,
|
||||||
" ": calc.ErrNotArithmetic,
|
" ": calc.ErrNotArithmetic,
|
||||||
"hello": calc.ErrNotArithmetic,
|
"hello": calc.ErrNotArithmetic,
|
||||||
@@ -89,21 +221,92 @@ func TestEvaluateRefuses(t *testing.T) {
|
|||||||
"2i * 2i": calc.ErrNotArithmetic,
|
"2i * 2i": calc.ErrNotArithmetic,
|
||||||
"0x10 + 1": calc.ErrNotArithmetic,
|
"0x10 + 1": calc.ErrNotArithmetic,
|
||||||
"1_000 + 1": calc.ErrNotArithmetic,
|
"1_000 + 1": calc.ErrNotArithmetic,
|
||||||
"7 % 2": calc.ErrNotArithmetic,
|
|
||||||
"2 ^ 3": calc.ErrNotArithmetic,
|
|
||||||
"1 << 10": calc.ErrNotArithmetic,
|
"1 << 10": calc.ErrNotArithmetic,
|
||||||
"1 == 1": calc.ErrNotArithmetic,
|
"1 == 1": calc.ErrNotArithmetic,
|
||||||
"!1": calc.ErrNotArithmetic,
|
"!1": calc.ErrNotArithmetic,
|
||||||
"func() int { return 1 }()": calc.ErrNotArithmetic,
|
"func() int { return 1 }()": calc.ErrNotArithmetic,
|
||||||
|
"(1 + 2": calc.ErrNotArithmetic,
|
||||||
|
"1 + 2)": calc.ErrNotArithmetic,
|
||||||
|
"()": calc.ErrNotArithmetic,
|
||||||
|
"(2)(3)": calc.ErrNotArithmetic,
|
||||||
|
"2 ^": calc.ErrNotArithmetic,
|
||||||
|
"^ 2": calc.ErrNotArithmetic,
|
||||||
|
"2 ^^ 3": calc.ErrNotArithmetic,
|
||||||
|
"2 *** 3": calc.ErrNotArithmetic,
|
||||||
|
"2 * * 3": calc.ErrNotArithmetic,
|
||||||
|
"% 3": calc.ErrNotArithmetic,
|
||||||
|
"50%": calc.ErrNotArithmetic,
|
||||||
|
"2 × 3": calc.ErrNotArithmetic,
|
||||||
"1 / 0": calc.ErrDivisionByZero,
|
"1 / 0": calc.ErrDivisionByZero,
|
||||||
"1 / (2 - 2)": calc.ErrDivisionByZero,
|
"1 / (2 - 2)": calc.ErrDivisionByZero,
|
||||||
"5 / 0.0": calc.ErrDivisionByZero,
|
"5 / 0.0": calc.ErrDivisionByZero,
|
||||||
"1e400": calc.ErrTooLarge,
|
"7 % 0": calc.ErrDivisionByZero,
|
||||||
"1e300 * 1e300": calc.ErrTooLarge,
|
"7.5 % (1 - 1)": calc.ErrDivisionByZero,
|
||||||
"1e999999999 * 1e999999999": calc.ErrTooLarge,
|
"0^-1": calc.ErrDivisionByZero,
|
||||||
"1 / 1e-400": calc.ErrTooLarge,
|
"0^-0.5": calc.ErrDivisionByZero,
|
||||||
|
"(-2)^0.5": calc.ErrNoRealResult,
|
||||||
|
"(-8)^(1/3)": calc.ErrNoRealResult,
|
||||||
|
"(-1)^-0.5": calc.ErrNoRealResult,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEvaluateOutOfRange: a number is held exactly, or computed in
|
||||||
|
// float64 as a normal double. Anything else is refused.
|
||||||
|
func TestEvaluateOutOfRange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expectErrors(t, map[string]error{
|
||||||
|
// Just past the 4096-bit limit, which 2^4094 and 10^1232 are
|
||||||
|
// under, and far past it.
|
||||||
|
"2^4095": calc.ErrOutOfRange,
|
||||||
|
"-2^4095": calc.ErrOutOfRange,
|
||||||
|
"2^-4095": calc.ErrOutOfRange,
|
||||||
|
"10^1233": calc.ErrOutOfRange,
|
||||||
|
"2^4094 * 2": calc.ErrOutOfRange,
|
||||||
|
"1e999999999 * 1e999999999": calc.ErrOutOfRange,
|
||||||
|
"2^5000": calc.ErrOutOfRange,
|
||||||
|
"(-2)^5001": calc.ErrOutOfRange,
|
||||||
|
"0.5^-5000": calc.ErrOutOfRange,
|
||||||
|
// Powers computed in float64 whose result is not a normal double,
|
||||||
|
// and so has lost digits, or all of them, whatever the size of
|
||||||
|
// the base.
|
||||||
|
"2^-1073.5 * 2^1073": calc.ErrOutOfRange,
|
||||||
|
"2^1500.5": calc.ErrOutOfRange,
|
||||||
|
"(2^1200)^0.9": calc.ErrOutOfRange,
|
||||||
|
"1e-400^0.9": calc.ErrOutOfRange,
|
||||||
|
"(2^2048)^0.5": calc.ErrOutOfRange,
|
||||||
|
"(2^-2046)^0.5": calc.ErrOutOfRange,
|
||||||
|
"(0.5^1100)^4 / (0.5^1100)^4": calc.ErrOutOfRange,
|
||||||
|
"(1/3)^1e400": calc.ErrOutOfRange,
|
||||||
|
// go/constant holds numbers of this size rounded. A sum of them
|
||||||
|
// can lose the answer (this one would be 0), and so can a
|
||||||
|
// remainder or the sign of -1 to such a power.
|
||||||
|
"7^1000 * 7^1000 + 5 - 7^1000 * 7^1000": calc.ErrOutOfRange,
|
||||||
|
"7^1000 * 7^1000 / 7^1000 % 10": calc.ErrOutOfRange,
|
||||||
|
"(-1)^(3^1365 * 3^1365 / 3^1365)": calc.ErrOutOfRange,
|
||||||
|
"(-1)^1e1300": calc.ErrOutOfRange,
|
||||||
|
"1e-1300": calc.ErrOutOfRange,
|
||||||
|
"1e-1300 + 1": calc.ErrOutOfRange,
|
||||||
|
"1e-700 * 1e-700": calc.ErrOutOfRange,
|
||||||
|
"0.1^800 * 0.1^800": calc.ErrOutOfRange,
|
||||||
|
// Both operands are held exactly, but their quotient is not.
|
||||||
|
"3^1365 % 7^-1000": calc.ErrOutOfRange,
|
||||||
|
// The same limit for a whole number made from x^0, which
|
||||||
|
// go/constant would hold as an integer and never round, and for
|
||||||
|
// a literal it reads exactly as a fraction past the limit.
|
||||||
|
"(2^0 + 2^0)^4095 % 10": calc.ErrOutOfRange,
|
||||||
|
"1e-1233 * 0": calc.ErrOutOfRange,
|
||||||
|
// go/constant reads this literal as 0.
|
||||||
|
"1e-999999999": calc.ErrOutOfRange,
|
||||||
|
"1 / 1e-999999999": calc.ErrOutOfRange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// expectErrors checks that each expression is refused with its error,
|
||||||
|
// and never with a panic.
|
||||||
|
func expectErrors(t *testing.T, cases map[string]error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
t.Run(in, func(t *testing.T) {
|
t.Run(in, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -116,6 +319,71 @@ func TestEvaluateRefuses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEvaluateBoundsWork: computed exactly, each of these powers would
|
||||||
|
// need more time and memory than any machine has. They must be answered
|
||||||
|
// at once.
|
||||||
|
func TestEvaluateBoundsWork(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{in: "9^9^9^9^9", err: calc.ErrOutOfRange},
|
||||||
|
{in: "((9^999)^999)^999", err: calc.ErrOutOfRange},
|
||||||
|
{in: "(3^2583)^4096", err: calc.ErrOutOfRange},
|
||||||
|
{in: "1.0000001^99999", want: "1.01005006557947"},
|
||||||
|
{in: "0.5^99999999999999999999", err: calc.ErrOutOfRange},
|
||||||
|
{in: "2^-9223372036854775808", err: calc.ErrOutOfRange},
|
||||||
|
{in: "(-1)^99999999999999999999", want: "-1"},
|
||||||
|
// The longest tower that fits.
|
||||||
|
{in: strings.Repeat("9^", 127) + "9", err: calc.ErrOutOfRange},
|
||||||
|
// The largest power of 3 computed exactly, as often as fits.
|
||||||
|
{in: "0" + strings.Repeat("*3^2583", 36), want: "0"},
|
||||||
|
// Whole numbers made from x^0, through each operation. Held as
|
||||||
|
// integers, which go/constant never rounds, they would escape
|
||||||
|
// the 4096-bit limit: the first needs about 69 billion bits.
|
||||||
|
{in: "(((2^0+2^0)^4096)^4096)^4096", err: calc.ErrOutOfRange},
|
||||||
|
{in: "(((0^0+0^0)^4096)^4096)^4096", err: calc.ErrOutOfRange},
|
||||||
|
{in: "(((-2^0-2^0)^4096)^4096)^4096", err: calc.ErrOutOfRange},
|
||||||
|
{in: "((2^0+2^0)^4000*(2^0+2^0)^4000)^4096", err: calc.ErrOutOfRange},
|
||||||
|
{in: "((((2^0+2^0)/2^0)^4096)^4096)^4096", err: calc.ErrOutOfRange},
|
||||||
|
{in: "((((2^0+2^0) % 3)^4096)^4096)^4096", err: calc.ErrOutOfRange},
|
||||||
|
{in: "(((2^0+2^0)^4096)^4096)^4096 * 0", err: calc.ErrOutOfRange},
|
||||||
|
// A fraction whose numerator and denominator are both just below
|
||||||
|
// the limit, and a literal whose exponent is too large to read.
|
||||||
|
{in: "(3^2583/5^1760)^4096", err: calc.ErrOutOfRange},
|
||||||
|
{in: "1e99999999999999999999", err: calc.ErrOutOfRange},
|
||||||
|
// Results just below the limit, written from their exact value.
|
||||||
|
{in: "2^4094", want: "2.6109722035328813e+1232"},
|
||||||
|
{in: "3^2583", want: "2.5363018640659988e+1232"},
|
||||||
|
{in: "2^-4094", want: "3.8299909843808741e-1233"},
|
||||||
|
{in: "-1/3^2583", want: "-3.9427483540814775e-1233"},
|
||||||
|
// Fractional powers of numbers just below the limit, whose bases
|
||||||
|
// take the most square roots to bring into the range of a double.
|
||||||
|
{in: "(2^-4094)^0.125", want: "8.869511863657883e-155"},
|
||||||
|
{in: "(1/3^2583)^0.5", err: calc.ErrOutOfRange},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.in, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
got, err := calc.Evaluate(c.in)
|
||||||
|
|
||||||
|
if elapsed := time.Since(start); elapsed > time.Second {
|
||||||
|
t.Errorf("Evaluate(%q) took %v", c.in, elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, c.err) || got != c.want {
|
||||||
|
t.Errorf("Evaluate(%q) = %q, %v; want %q, %v", c.in, got, err, c.want, c.err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestEvaluateCapsInput: the length cap is what bounds the work a
|
// TestEvaluateCapsInput: the length cap is what bounds the work a
|
||||||
// message can cause, so it must hold exactly at the boundary.
|
// message can cause, so it must hold exactly at the boundary.
|
||||||
func TestEvaluateCapsInput(t *testing.T) {
|
func TestEvaluateCapsInput(t *testing.T) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
// RootCmd and Execute are rootCmd and execute, exported for the external
|
||||||
|
// test package, which builds the command tree itself so that it can
|
||||||
|
// supply the arguments and read the output.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // test seams, not mutable state.
|
||||||
|
var (
|
||||||
|
RootCmd = rootCmd
|
||||||
|
Execute = execute
|
||||||
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// environment (see internal/config), not from flags: the bot runs in a
|
// environment (see internal/config), not from flags: the bot runs in a
|
||||||
// container, where the environment is the interface, and one source of
|
// container, where the environment is the interface, and one source of
|
||||||
// truth means there is no precedence rule to get wrong.
|
// truth means there is no precedence rule to get wrong.
|
||||||
func rootCmd() *cobra.Command {
|
func rootCmd(version string) *cobra.Command {
|
||||||
root := &cobra.Command{
|
root := &cobra.Command{
|
||||||
Use: appname,
|
Use: appname,
|
||||||
Short: appname + " — a SimpleX Chat bot that answers arithmetic",
|
Short: appname + " — a SimpleX Chat bot that answers arithmetic",
|
||||||
@@ -26,12 +26,12 @@ func rootCmd() *cobra.Command {
|
|||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
root.AddCommand(runCmd(), versionCmd())
|
root.AddCommand(runCmd(version), versionCmd(version))
|
||||||
|
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
func versionCmd() *cobra.Command {
|
func versionCmd(version string) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "version",
|
Use: "version",
|
||||||
Short: "print the version and exit",
|
Short: "print the version and exit",
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
"sneak.berlin/go/simplexcalc/internal/logger"
|
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runCmd() *cobra.Command {
|
func runCmd(version string) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "run",
|
Use: "run",
|
||||||
Short: "run the bot",
|
Short: "run the bot",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return run(cmd.Context())
|
return run(cmd.Context(), version)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ func runCmd() *cobra.Command {
|
|||||||
// run reads the configuration and runs the bot until SIGINT or SIGTERM,
|
// run reads the configuration and runs the bot until SIGINT or SIGTERM,
|
||||||
// or until it fails. A failure is the process's exit status, so a
|
// or until it fails. A failure is the process's exit status, so a
|
||||||
// supervisor restarts it.
|
// supervisor restarts it.
|
||||||
func run(ctx context.Context) error {
|
func run(ctx context.Context, version string) error {
|
||||||
cfg, err := config.New()
|
cfg, err := config.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading configuration: %w", err)
|
return fmt.Errorf("reading configuration: %w", err)
|
||||||
@@ -45,7 +45,7 @@ func run(ctx context.Context) error {
|
|||||||
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
err = bot.Run(ctx, log, cfg.DataDir)
|
err = bot.Run(ctx, log, cfg, bot.ChatPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("stopped", "error", err)
|
log.Error("stopped", "error", err)
|
||||||
|
|
||||||
@@ -16,8 +16,10 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
@@ -36,10 +38,21 @@ import (
|
|||||||
const (
|
const (
|
||||||
EnvDataDir = "DATA_DIR"
|
EnvDataDir = "DATA_DIR"
|
||||||
EnvDebug = "DEBUG"
|
EnvDebug = "DEBUG"
|
||||||
|
EnvPort = "PORT"
|
||||||
|
EnvAPITokenFile = "API_TOKEN_FILE" //nolint:gosec // G101: a name, not a credential
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultDataDir applies when DATA_DIR is absent.
|
// Defaults, for the variables that are absent.
|
||||||
const DefaultDataDir = "./data"
|
const (
|
||||||
|
DefaultDataDir = "./data"
|
||||||
|
DefaultPort = 8080
|
||||||
|
)
|
||||||
|
|
||||||
|
// MinAPITokenLength is the fewest characters the API credential may
|
||||||
|
// have, not counting whitespace around it.
|
||||||
|
const MinAPITokenLength = 32
|
||||||
|
|
||||||
|
const maxPort = 65535
|
||||||
|
|
||||||
// ErrInvalidConfig is the sentinel every configuration failure wraps,
|
// ErrInvalidConfig is the sentinel every configuration failure wraps,
|
||||||
// so callers can distinguish "the operator got it wrong" from "the
|
// so callers can distinguish "the operator got it wrong" from "the
|
||||||
@@ -51,10 +64,19 @@ var ErrInvalidConfig = errors.New("invalid configuration")
|
|||||||
// later, so there is exactly one moment at which configuration can be
|
// later, so there is exactly one moment at which configuration can be
|
||||||
// wrong, and it is before anything starts.
|
// wrong, and it is before anything starts.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// DataDir holds the SimpleX Chat database: the bot's profile, its
|
// DataDir holds the SimpleX Chat database, with the bot's profile,
|
||||||
// address and its contacts. Losing it loses the address.
|
// its address and its contacts, and the API's webhooks. Losing it
|
||||||
|
// loses the address.
|
||||||
DataDir string
|
DataDir string
|
||||||
Debug bool
|
Debug bool
|
||||||
|
|
||||||
|
// Port is the API's TCP port.
|
||||||
|
Port int
|
||||||
|
|
||||||
|
// APIToken is the credential every API request must carry, read
|
||||||
|
// from the file named by API_TOKEN_FILE. Empty when that is absent,
|
||||||
|
// and then the API refuses every request. Never log it.
|
||||||
|
APIToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// loader parses one environment into a Config, accumulating every
|
// loader parses one environment into a Config, accumulating every
|
||||||
@@ -109,6 +131,53 @@ func (l *loader) boolean(key string, def bool) bool {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// port accepts a whole number from 1 to 65535 and refuses everything
|
||||||
|
// else.
|
||||||
|
func (l *loader) port(key string, def int) int {
|
||||||
|
s, ok := l.raw(key)
|
||||||
|
if !ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil || n < 1 || n > maxPort {
|
||||||
|
l.fail(key, s, "not a port (use a whole number from 1 to 65535)")
|
||||||
|
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenFile returns the credential held in the file named by key, with
|
||||||
|
// the whitespace around it trimmed, or "" when key is absent. A file
|
||||||
|
// that cannot be read, or holds fewer than MinAPITokenLength
|
||||||
|
// characters, is a failure; the message names the file, never what it
|
||||||
|
// holds.
|
||||||
|
func (l *loader) tokenFile(key string) string {
|
||||||
|
path, ok := l.raw(key)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := os.ReadFile(path) //nolint:gosec // G304: the operator names the file.
|
||||||
|
if err != nil {
|
||||||
|
l.fail(key, path, "unreadable: "+err.Error())
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(string(b))
|
||||||
|
if utf8.RuneCountInString(token) < MinAPITokenLength {
|
||||||
|
l.fail(key, path, fmt.Sprintf("a file holding fewer than %d characters",
|
||||||
|
MinAPITokenLength))
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
// New parses and validates the environment. An error here aborts
|
// New parses and validates the environment. An error here aborts
|
||||||
// startup before the chat client is launched, so there is no partially
|
// startup before the chat client is launched, so there is no partially
|
||||||
// configured running state to reason about.
|
// configured running state to reason about.
|
||||||
@@ -127,6 +196,8 @@ func load(v *viper.Viper) (*Config, error) {
|
|||||||
c := &Config{
|
c := &Config{
|
||||||
DataDir: l.str(EnvDataDir, DefaultDataDir),
|
DataDir: l.str(EnvDataDir, DefaultDataDir),
|
||||||
Debug: l.boolean(EnvDebug, false),
|
Debug: l.boolean(EnvDebug, false),
|
||||||
|
Port: l.port(EnvPort, DefaultPort),
|
||||||
|
APIToken: l.tokenFile(EnvAPITokenFile),
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(l.errs) > 0 {
|
if len(l.errs) > 0 {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package config_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -27,7 +30,10 @@ func TestAbsentValuesTakeDefaults(t *testing.T) {
|
|||||||
|
|
||||||
for name, kv := range map[string]map[string]string{
|
for name, kv := range map[string]map[string]string{
|
||||||
"unset": nil,
|
"unset": nil,
|
||||||
"whitespace only": {config.EnvDataDir: " ", config.EnvDebug: " "},
|
"whitespace only": {
|
||||||
|
config.EnvDataDir: " ", config.EnvDebug: " ",
|
||||||
|
config.EnvPort: " ", config.EnvAPITokenFile: "\t",
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -44,6 +50,14 @@ func TestAbsentValuesTakeDefaults(t *testing.T) {
|
|||||||
if c.Debug {
|
if c.Debug {
|
||||||
t.Error("Debug must default off")
|
t.Error("Debug must default off")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Port != config.DefaultPort {
|
||||||
|
t.Errorf("Port = %d, want %d", c.Port, config.DefaultPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.APIToken != "" {
|
||||||
|
t.Error("APIToken must default to none")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,3 +108,79 @@ func TestValidValuesAreUsed(t *testing.T) {
|
|||||||
t.Error("Debug = false, want true")
|
t.Error("Debug = false, want true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPort: PORT is a whole number from 1 to 65535, and anything else
|
||||||
|
// aborts.
|
||||||
|
func TestPort(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for raw, want := range map[string]int{"1": 1, "8081": 8081, "65535": 65535} {
|
||||||
|
c, err := config.Load(env(map[string]string{config.EnvPort: raw}))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("PORT=%q was rejected: %v", raw, err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Port != want {
|
||||||
|
t.Errorf("PORT=%q: Port = %d, want %d", raw, c.Port, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range []string{"0", "65536", "-1", "80.5", "8080x", "http"} {
|
||||||
|
_, err := config.Load(env(map[string]string{config.EnvPort: raw}))
|
||||||
|
if !errors.Is(err, config.ErrInvalidConfig) {
|
||||||
|
t.Errorf("PORT=%q: error = %v, want ErrInvalidConfig", raw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAPITokenFile: the credential is the file's content without the
|
||||||
|
// whitespace around it. A file that cannot be read, or holds too short
|
||||||
|
// a credential, aborts, and the error never shows what the file holds.
|
||||||
|
func TestAPITokenFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
token := strings.Repeat("k", config.MinAPITokenLength)
|
||||||
|
short := strings.Repeat("s", config.MinAPITokenLength-1)
|
||||||
|
|
||||||
|
for name, content := range map[string]string{
|
||||||
|
"good": " " + token + "\n",
|
||||||
|
"short": "\n" + short + " \n",
|
||||||
|
} {
|
||||||
|
err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load := func(name string) (*config.Config, error) {
|
||||||
|
return config.Load(env(map[string]string{
|
||||||
|
config.EnvAPITokenFile: filepath.Join(dir, name),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := load("good")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a good file was rejected: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.APIToken != token {
|
||||||
|
t.Errorf("APIToken = %q, want %q", c.APIToken, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = load("missing")
|
||||||
|
if !errors.Is(err, config.ErrInvalidConfig) {
|
||||||
|
t.Errorf("a missing file: error = %v, want ErrInvalidConfig", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = load("short")
|
||||||
|
if !errors.Is(err, config.ErrInvalidConfig) {
|
||||||
|
t.Fatalf("a short credential: error = %v, want ErrInvalidConfig", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(err.Error(), short) {
|
||||||
|
t.Errorf("the error shows the file's content: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+108
-3
@@ -14,6 +14,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -21,15 +22,33 @@ import (
|
|||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxMessageSize bounds one message from the chat client. The largest
|
// maxMessageSize bounds one message from the chat client; a larger one
|
||||||
// thing it sends is a record carrying a contact's profile picture, well
|
// ends the connection. The largest it sends are the pages of chat items
|
||||||
// under this.
|
// ChatItems asks for, which chatItemsPage keeps under this.
|
||||||
const maxMessageSize = 16 << 20
|
const maxMessageSize = 16 << 20
|
||||||
|
|
||||||
|
// chatItemsPage is how many chat items ChatItems asks for at a time. An
|
||||||
|
// item repeats a message's text and spells out its formatting, which for
|
||||||
|
// text made of short mentions makes it 26 times as long as the text, and
|
||||||
|
// a contact's message can hold 64 KiB: one item can take 1.7 MiB. Five
|
||||||
|
// stay well under maxMessageSize.
|
||||||
|
const chatItemsPage = 5
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// ErrClosed is returned by Command once the connection has ended.
|
// ErrClosed is returned by Command once the connection has ended.
|
||||||
ErrClosed = errors.New("connection to the chat client closed")
|
ErrClosed = errors.New("connection to the chat client closed")
|
||||||
|
|
||||||
|
// ErrNoContact is returned for a contact the user does not have.
|
||||||
|
ErrNoContact = errors.New("no such contact")
|
||||||
|
|
||||||
|
// ErrContactNotReady is returned for sending to a contact who cannot
|
||||||
|
// receive messages: one who has deleted their chat with the user, or
|
||||||
|
// who has not finished connecting.
|
||||||
|
ErrContactNotReady = errors.New("the contact cannot receive messages")
|
||||||
|
|
||||||
|
// ErrMessageTooLarge is returned for a message too large to send.
|
||||||
|
ErrMessageTooLarge = errors.New("the message is too large")
|
||||||
|
|
||||||
errUnexpected = errors.New("unexpected response")
|
errUnexpected = errors.New("unexpected response")
|
||||||
errCommand = errors.New("command failed")
|
errCommand = errors.New("command failed")
|
||||||
)
|
)
|
||||||
@@ -168,6 +187,55 @@ func (c *Client) SetAddressSettings(
|
|||||||
return c.command(ctx, cmd, TypeUserContactLinkUpdated, nil)
|
return c.command(ctx, cmd, TypeUserContactLinkUpdated, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contacts returns the user's contacts: everyone it has a direct chat
|
||||||
|
// with.
|
||||||
|
func (c *Client) Contacts(ctx context.Context, userID int64) ([]Contact, error) {
|
||||||
|
var r struct {
|
||||||
|
Contacts []Contact `json:"contacts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.command(ctx, cmdListContacts(userID), TypeContactsList, &r)
|
||||||
|
|
||||||
|
return r.Contacts, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatItems returns the last count items of the chat with a contact,
|
||||||
|
// oldest first, or all of them if the chat has fewer.
|
||||||
|
func (c *Client) ChatItems(
|
||||||
|
ctx context.Context, contactID int64, count int,
|
||||||
|
) ([]ChatItem, error) {
|
||||||
|
var (
|
||||||
|
items []ChatItem
|
||||||
|
before int64 // 0 asks for the chat's last items
|
||||||
|
)
|
||||||
|
|
||||||
|
for len(items) < count {
|
||||||
|
page := min(count-len(items), chatItemsPage)
|
||||||
|
|
||||||
|
//nolint:tagliatelle // the chat client's wire format.
|
||||||
|
var r struct {
|
||||||
|
Chat struct {
|
||||||
|
ChatItems []ChatItem `json:"chatItems"`
|
||||||
|
} `json:"chat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.command(ctx, cmdGetChat(contactID, before, page), TypeAPIChat, &r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items = slices.Concat(r.Chat.ChatItems, items)
|
||||||
|
|
||||||
|
if len(r.Chat.ChatItems) < page {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
before = items[0].Meta.ItemID
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SendText sends a text message to a contact, as a reply to the message
|
// SendText sends a text message to a contact, as a reply to the message
|
||||||
// quotedItemID (0 for none). It does not wait for the chat client to
|
// quotedItemID (0 for none). It does not wait for the chat client to
|
||||||
// accept it; a failure is logged when the client's answer arrives.
|
// accept it; a failure is logged when the client's answer arrives.
|
||||||
@@ -184,6 +252,32 @@ func (c *Client) SendText(contactID, quotedItemID int64, text string) error {
|
|||||||
return c.write(id, cmd)
|
return c.write(id, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendMessage sends a text message to a contact and returns it as the
|
||||||
|
// chat client recorded it. Unlike SendText, it waits for the chat
|
||||||
|
// client's answer, so an EventHandler must never call it.
|
||||||
|
func (c *Client) SendMessage(
|
||||||
|
ctx context.Context, contactID int64, text string,
|
||||||
|
) (ChatItem, error) {
|
||||||
|
cmd, err := cmdSendText(contactID, 0, text)
|
||||||
|
if err != nil {
|
||||||
|
return ChatItem{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var r NewChatItems
|
||||||
|
|
||||||
|
err = c.command(ctx, cmd, TypeNewChatItems, &r)
|
||||||
|
if err != nil {
|
||||||
|
return ChatItem{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(r.ChatItems) != 1 {
|
||||||
|
return ChatItem{}, fmt.Errorf("%w to %q: %d chat items",
|
||||||
|
errUnexpected, cmdName(cmd), len(r.ChatItems))
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.ChatItems[0].ChatItem, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CommandError is a command the chat client refused. Type and Detail
|
// CommandError is a command the chat client refused. Type and Detail
|
||||||
// are the discriminators of its chatError record, such as "errorStore"
|
// are the discriminators of its chatError record, such as "errorStore"
|
||||||
// and "userContactLinkNotFound".
|
// and "userContactLinkNotFound".
|
||||||
@@ -329,6 +423,8 @@ func (c *Client) dispatch(data []byte) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// commandError returns the error in a chatCmdError record, marked with
|
||||||
|
// this package's error for the refusals that have one.
|
||||||
func commandError(ev Event) error {
|
func commandError(ev Event) error {
|
||||||
var r cmdError
|
var r cmdError
|
||||||
|
|
||||||
@@ -347,8 +443,17 @@ func commandError(ev Event) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch e.Detail {
|
||||||
|
case "contactNotFound":
|
||||||
|
return fmt.Errorf("%w: %w", ErrNoContact, e)
|
||||||
|
case "contactNotReady":
|
||||||
|
return fmt.Errorf("%w: %w", ErrContactNotReady, e)
|
||||||
|
case "largeMsg":
|
||||||
|
return fmt.Errorf("%w: %w", ErrMessageTooLarge, e)
|
||||||
|
default:
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cmdName is a command without its arguments, for error messages: the
|
// cmdName is a command without its arguments, for error messages: the
|
||||||
// arguments of /_send are a message someone wrote.
|
// arguments of /_send are a message someone wrote.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -43,12 +44,25 @@ const (
|
|||||||
|
|
||||||
contactConnected = `{"type":"contactConnected","user":{"userId":1},
|
contactConnected = `{"type":"contactConnected","user":{"userId":1},
|
||||||
"contact":{"contactId":3,"localDisplayName":"alice"}}`
|
"contact":{"contactId":3,"localDisplayName":"alice"}}`
|
||||||
|
|
||||||
|
// Two contacts with the same display name, which the chat client
|
||||||
|
// tells apart by the local name it gives the second. The second has
|
||||||
|
// deleted its chat with the bot, and is still listed.
|
||||||
|
contactsList = `{"type":"contactsList","user":{"userId":1},"contacts":[
|
||||||
|
{"contactId":3,"localDisplayName":"tester","profile":{"profileId":3,
|
||||||
|
"displayName":"tester","fullName":"","localAlias":""},
|
||||||
|
"activeConn":{"connId":2,"connStatus":{"type":"ready"}},
|
||||||
|
"contactUsed":true,"contactStatus":"active","chatDeleted":false},
|
||||||
|
{"contactId":4,"localDisplayName":"tester_1","profile":{"profileId":4,
|
||||||
|
"displayName":"tester","fullName":"","localAlias":""},
|
||||||
|
"activeConn":{"connId":3,"connStatus":{"type":"deleted"}},
|
||||||
|
"contactUsed":true,"contactStatus":"deleted","chatDeleted":false}]}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeChat stands in for the chat client's API. It answers each command
|
// fakeChat stands in for the chat client's API. It answers each command
|
||||||
// with the record in replies under the command's first word, stays
|
// with the record in replies under the whole command or else under its
|
||||||
// silent for a command it has no record for, and reports every command
|
// first word, stays silent for a command it has no record for, and
|
||||||
// it receives on got.
|
// reports every command it receives on got.
|
||||||
type fakeChat struct {
|
type fakeChat struct {
|
||||||
replies map[string]string
|
replies map[string]string
|
||||||
got chan string
|
got chan string
|
||||||
@@ -98,8 +112,13 @@ func (f *fakeChat) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
f.got <- cmd.Cmd
|
f.got <- cmd.Cmd
|
||||||
|
|
||||||
|
resp, ok := f.replies[cmd.Cmd]
|
||||||
|
if !ok {
|
||||||
name, _, _ := strings.Cut(cmd.Cmd, " ")
|
name, _, _ := strings.Cut(cmd.Cmd, " ")
|
||||||
if resp, ok := f.replies[name]; ok {
|
resp, ok = f.replies[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
f.send(cmd.CorrID, resp)
|
f.send(cmd.CorrID, resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,6 +239,38 @@ func TestAddressSetup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestContacts: the contacts of the given user come back with their
|
||||||
|
// ids, display names and whether they are deleted.
|
||||||
|
func TestContacts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f, url := newFakeChat(t, map[string]string{"/_contacts": contactsList})
|
||||||
|
c, ctx := dial(t, url, nil)
|
||||||
|
|
||||||
|
contacts, err := c.Contacts(ctx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Contacts: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := f.next(t); got != "/_contacts 1" {
|
||||||
|
t.Errorf("command = %s, want /_contacts 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
tester := simplex.Profile{DisplayName: "tester"}
|
||||||
|
|
||||||
|
want := []simplex.Contact{
|
||||||
|
{ContactID: 3, Profile: tester, Status: "active"},
|
||||||
|
{ContactID: 4, Profile: tester, Status: "deleted"},
|
||||||
|
}
|
||||||
|
if !slices.Equal(contacts, want) {
|
||||||
|
t.Fatalf("Contacts = %+v\nwant %+v", contacts, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if contacts[0].Deleted() || !contacts[1].Deleted() {
|
||||||
|
t.Error("Deleted must be false for contact 3 and true for contact 4")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestRefusedCommand: a command the chat client refuses is an error
|
// TestRefusedCommand: a command the chat client refuses is an error
|
||||||
// that names the reason.
|
// that names the reason.
|
||||||
func TestRefusedCommand(t *testing.T) {
|
func TestRefusedCommand(t *testing.T) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Response and event types this package and the bot act on. The chat
|
// Response and event types this package and the bot act on. The chat
|
||||||
@@ -14,6 +15,8 @@ const (
|
|||||||
TypeUserContactLink = "userContactLink"
|
TypeUserContactLink = "userContactLink"
|
||||||
TypeUserContactLinkCreated = "userContactLinkCreated"
|
TypeUserContactLinkCreated = "userContactLinkCreated"
|
||||||
TypeUserContactLinkUpdated = "userContactLinkUpdated"
|
TypeUserContactLinkUpdated = "userContactLinkUpdated"
|
||||||
|
TypeContactsList = "contactsList"
|
||||||
|
TypeAPIChat = "apiChat"
|
||||||
TypeNewChatItems = "newChatItems"
|
TypeNewChatItems = "newChatItems"
|
||||||
TypeContactConnected = "contactConnected"
|
TypeContactConnected = "contactConnected"
|
||||||
TypeChatCmdError = "chatCmdError"
|
TypeChatCmdError = "chatCmdError"
|
||||||
@@ -47,9 +50,13 @@ type (
|
|||||||
// User is the chat client's local user profile: the bot itself.
|
// User is the chat client's local user profile: the bot itself.
|
||||||
User struct {
|
User struct {
|
||||||
UserID int64 `json:"userId"`
|
UserID int64 `json:"userId"`
|
||||||
|
Profile Profile `json:"profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile is how the bot or a contact presents itself. Nothing
|
||||||
|
// makes a display name unique.
|
||||||
Profile struct {
|
Profile struct {
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
} `json:"profile"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnLink is a SimpleX link. The short form is what people share;
|
// ConnLink is a SimpleX link. The short form is what people share;
|
||||||
@@ -62,10 +69,13 @@ type (
|
|||||||
// Contact is a person connected to the bot.
|
// Contact is a person connected to the bot.
|
||||||
Contact struct {
|
Contact struct {
|
||||||
ContactID int64 `json:"contactId"`
|
ContactID int64 `json:"contactId"`
|
||||||
|
Profile Profile `json:"profile"`
|
||||||
|
Status string `json:"contactStatus"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChatItems is the record of a newChatItems event: messages
|
// NewChatItems is the record of a newChatItems event, messages
|
||||||
// received, or sent from this profile elsewhere.
|
// received or sent from this profile elsewhere, and of the answer
|
||||||
|
// to sending a message.
|
||||||
NewChatItems struct {
|
NewChatItems struct {
|
||||||
ChatItems []AChatItem `json:"chatItems"`
|
ChatItems []AChatItem `json:"chatItems"`
|
||||||
}
|
}
|
||||||
@@ -75,25 +85,36 @@ type (
|
|||||||
Contact Contact `json:"contact"`
|
Contact Contact `json:"contact"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AChatItem is one message together with the chat it belongs to.
|
// AChatItem is one chat item together with the chat it belongs to.
|
||||||
AChatItem struct {
|
AChatItem struct {
|
||||||
ChatInfo struct {
|
ChatInfo struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Contact *Contact `json:"contact,omitempty"`
|
Contact *Contact `json:"contact,omitempty"`
|
||||||
} `json:"chatInfo"`
|
} `json:"chatInfo"`
|
||||||
|
ChatItem ChatItem `json:"chatItem"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatItem is one item in a chat: a message, or an event the chat
|
||||||
|
// client records there, such as the contact connecting. Content.Type
|
||||||
|
// tells them apart: "rcvMsgContent" and "sndMsgContent" are messages
|
||||||
|
// received and sent.
|
||||||
ChatItem struct {
|
ChatItem struct {
|
||||||
ChatDir tagged `json:"chatDir"`
|
ChatDir tagged `json:"chatDir"`
|
||||||
Meta struct {
|
Meta struct {
|
||||||
ItemID int64 `json:"itemId"`
|
ItemID int64 `json:"itemId"`
|
||||||
|
// ItemTs is when a received message reached the SimpleX
|
||||||
|
// relay, and when a sent one was sent.
|
||||||
|
ItemTs time.Time `json:"itemTs"`
|
||||||
} `json:"meta"`
|
} `json:"meta"`
|
||||||
Content struct {
|
Content struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
MsgContent *MsgContent `json:"msgContent,omitempty"`
|
MsgContent *MsgContent `json:"msgContent,omitempty"`
|
||||||
} `json:"content"`
|
} `json:"content"`
|
||||||
} `json:"chatItem"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MsgContent is a message body. Only "text" is sent or read here.
|
// MsgContent is a message body: its type, such as "text", "image"
|
||||||
|
// or "file", and its text, which for anything but "text" is the
|
||||||
|
// caption. Only "text" is sent here.
|
||||||
MsgContent struct {
|
MsgContent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
@@ -142,6 +163,13 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Deleted reports whether the contact is gone, as it is once the person
|
||||||
|
// deletes their chat with the bot. The chat client still lists such a
|
||||||
|
// contact, with its chat, but nothing more reaches them.
|
||||||
|
func (c Contact) Deleted() bool {
|
||||||
|
return c.Status != "active"
|
||||||
|
}
|
||||||
|
|
||||||
// Message is a text message a contact sent to the bot.
|
// Message is a text message a contact sent to the bot.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ContactID int64
|
ContactID int64
|
||||||
@@ -190,6 +218,24 @@ func cmdSetAddressSettings(userID int64, s AddressSettings) (string, error) {
|
|||||||
return "/_address_settings " + strconv.FormatInt(userID, 10) + " " + string(b), nil
|
return "/_address_settings " + strconv.FormatInt(userID, 10) + " " + string(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cmdListContacts(userID int64) string {
|
||||||
|
return "/_contacts " + strconv.FormatInt(userID, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdGetChat asks for the last count items of the chat with a contact,
|
||||||
|
// or, unless beforeItemID is 0, the last count before that item. It is
|
||||||
|
// missing from COMMANDS.md; its syntax is the client's parser's, in
|
||||||
|
// src/Simplex/Chat/Library/Commands.hs.
|
||||||
|
func cmdGetChat(contactID, beforeItemID int64, count int) string {
|
||||||
|
cmd := "/_get chat @" + strconv.FormatInt(contactID, 10)
|
||||||
|
|
||||||
|
if beforeItemID != 0 {
|
||||||
|
cmd += " before=" + strconv.FormatInt(beforeItemID, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd + " count=" + strconv.Itoa(count)
|
||||||
|
}
|
||||||
|
|
||||||
func cmdSendText(contactID, quotedItemID int64, text string) (string, error) {
|
func cmdSendText(contactID, quotedItemID int64, text string) (string, error) {
|
||||||
b, err := json.Marshal([]composedMessage{{
|
b, err := json.Marshal([]composedMessage{{
|
||||||
QuotedItemID: quotedItemID,
|
QuotedItemID: quotedItemID,
|
||||||
|
|||||||
Reference in New Issue
Block a user