HTTP API: server, credential and the list of chats #12

Merged
clawbot merged 7 commits from issue-4-api-chats into next 2026-09-29 04:55:50 +02:00
17 changed files with 908 additions and 35 deletions
Showing only changes of commit 17d6be9fe1 - Show all commits
+2 -1
View File
@@ -1,6 +1,6 @@
--- ---
title: Agent Guidance title: Agent Guidance
last_modified: 2026-09-28 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
@@ -119,6 +119,7 @@ Do not weaken them.
``` ```
cmd/simplexcalc/ main(), a single call into internal/cli 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: go/parser and go/constant
internal/cli/ cobra command tree: run and version internal/cli/ cobra command tree: run and version
+4
View File
@@ -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"]
+79 -5
View File
@@ -11,18 +11,28 @@ 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:clawbot/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
@@ -67,6 +77,60 @@ variables that are absent.
`/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. 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.
## Entrypoints ## Entrypoints
@@ -142,8 +206,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,6 +218,16 @@ 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; 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.
- **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.
+3
View File
@@ -27,6 +27,9 @@ with no deprecation warning.
# Completed Steps # Completed Steps
- 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 Moved the command tree and the `run` and `version` commands - 2026-09-28 Moved the command tree and the `run` and `version` commands
from `cmd/simplexcalc/` into `internal/cli`; `cmd/simplexcalc/main.go` from `cmd/simplexcalc/` into `internal/cli`; `cmd/simplexcalc/main.go`
is now a single call to `cli.Main` is now a single call to `cli.Main`
+1
View File
@@ -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
+2
View File
@@ -5,6 +5,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/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=
+180
View File
@@ -0,0 +1,180 @@
// Package api is the bot's HTTP API, through which another program
// reads the bot's chats.
//
// Every request must carry the credential, as "Authorization: Bearer
// {credential}"; with no credential configured, every request is
// refused. No path is exempt.
//
// Handlers call the chat client on the request's own goroutine. Doing
// so from the chat client's event handler would wait forever, since
// that goroutine also delivers the responses (see simplex.EventHandler).
package api
import (
"context"
"crypto/subtle"
"encoding/json"
"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)
}
// 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
}
// 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}
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())
})
return &http.Server{
Addr: ":" + strconv.Itoa(p.Port),
Handler: router,
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
// 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
}
// 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})
}
// securityHeaders go on every response. The API returns JSON to
// programs, so a browser may not frame, sniff, cache or refer from it,
// 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("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))
})
}
+214
View File
@@ -0,0 +1,214 @@
package api_test
import (
"bytes"
"context"
"errors"
"log/slog"
"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"
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,
// or with err, and remembers what it was asked.
type fakeClient struct {
contacts []simplex.Contact
err error
userID int64
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.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",
},
} {
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)
}
}
}
// 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",
"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{err: 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)
}
}
}
+46
View File
@@ -0,0 +1,46 @@
package api
import (
"cmp"
"net/http"
"slices"
)
// handleChats lists the bot's chats, ordered by id. The bot talks to
// people only one to one, so its chats are its contacts, and a chat's
// id is its contact's.
func (h *handlers) handleChats() http.HandlerFunc {
type chat struct {
ID int64 `json:"id"`
DisplayName string `json:"display_name"`
ContactDeleted bool `json:"contact_deleted"`
}
type response struct {
Chats []chat `json:"chats"`
}
return func(w http.ResponseWriter, r *http.Request) {
contacts, err := h.client.Contacts(r.Context(), h.userID)
if err != nil {
h.log.Error("listing the chats", "error", err)
h.respondError(w, http.StatusInternalServerError,
"the chats could not be read")
return
}
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) })
h.respond(w, http.StatusOK, response{Chats: chats})
}
}
+64
View File
@@ -0,0 +1,64 @@
package api_test
import (
"net/http"
"testing"
"sneak.berlin/go/simplexcalc/internal/simplex"
)
// TestChats: the chats are the bot's contacts, ordered by id, deleted
// ones marked, and the chat client is asked for the bot's user with a
// deadline.
func TestChats(t *testing.T) {
t.Parallel()
client := &fakeClient{contacts: []simplex.Contact{
{ContactID: 3, Profile: simplex.Profile{DisplayName: "bob"}, Status: "deleted"},
{ContactID: 2, Profile: simplex.Profile{DisplayName: "alice"}, Status: "active"},
}}
rec := request(t, newAPI(credential, client), http.MethodGet, chatsPath, bearer)
want := `{"chats":[{"id":2,"display_name":"alice","contact_deleted":false},` +
`{"id":3,"display_name":"bob","contact_deleted":true}]}` + "\n"
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want)
}
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
if client.userID != 1 || !client.hadDeadline {
t.Errorf("the chat client was asked for user %d, deadline %v; "+
"want user 1 with a deadline", client.userID, client.hadDeadline)
}
}
// TestNoChats: no contacts is an empty list, not null.
func TestNoChats(t *testing.T) {
t.Parallel()
rec := request(t, newAPI(credential, &fakeClient{}),
http.MethodGet, chatsPath, bearer)
want := `{"chats":[]}` + "\n"
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want)
}
}
// TestChatsFailure: when the chat client fails, the response says so
// in a chosen sentence, never in the error's own text.
func TestChatsFailure(t *testing.T) {
t.Parallel()
rec := request(t, newAPI(credential, &fakeClient{err: 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)
}
}
+63 -15
View File
@@ -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"
) )
@@ -42,17 +45,22 @@ 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 starts the chat client with its database in cfg.DataDir, connects
// it, sets up the bot's address, and answers messages until ctx is // to it, sets up the bot's address, then answers messages and serves the
// cancelled — which is a clean stop and returns nil — or until the chat // API until ctx is cancelled — which is a clean stop and returns nil —
// client or the connection to it fails, which returns the error. // or until the chat client, the connection to it or the API's listener
func Run(ctx context.Context, log *slog.Logger, dataDir string) error { // fails, which returns the error.
err := os.MkdirAll(dataDir, dataDirMode) func Run(ctx context.Context, log *slog.Logger, cfg *config.Config) 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)
} }
@@ -61,7 +69,7 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
// 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) cliCtx, stopCLI := context.WithCancel(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()
@@ -81,11 +89,31 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
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,
})
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,6 +121,23 @@ 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)
}
}
// 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()
} }
} }
@@ -125,19 +170,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 +193,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 +202,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,7 +211,7 @@ 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.
+1 -1
View File
@@ -45,7 +45,7 @@ func run(ctx context.Context, version string) 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)
if err != nil { if err != nil {
log.Error("stopped", "error", err) log.Error("stopped", "error", err)
+76 -6
View File
@@ -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"
@@ -34,12 +36,23 @@ import (
// Environment variable names. Bare names, no prefix: this matches the // Environment variable names. Bare names, no prefix: this matches the
// other services and keeps a compose file readable. // other services and keeps a compose file readable.
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
@@ -55,6 +68,14 @@ type Config struct {
// address and its contacts. Losing it loses the address. // address and its contacts. 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 +130,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.
@@ -125,8 +193,10 @@ func load(v *viper.Viper) (*Config, error) {
l := &loader{v: v} l := &loader{v: v}
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 {
+92 -2
View File
@@ -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"
@@ -26,8 +29,11 @@ func TestAbsentValuesTakeDefaults(t *testing.T) {
t.Parallel() t.Parallel()
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)
}
}
+12
View File
@@ -168,6 +168,18 @@ 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
}
// 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.
+46
View File
@@ -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,6 +44,19 @@ 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
@@ -220,6 +234,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) {
+23 -5
View File
@@ -14,6 +14,7 @@ const (
TypeUserContactLink = "userContactLink" TypeUserContactLink = "userContactLink"
TypeUserContactLinkCreated = "userContactLinkCreated" TypeUserContactLinkCreated = "userContactLinkCreated"
TypeUserContactLinkUpdated = "userContactLinkUpdated" TypeUserContactLinkUpdated = "userContactLinkUpdated"
TypeContactsList = "contactsList"
TypeNewChatItems = "newChatItems" TypeNewChatItems = "newChatItems"
TypeContactConnected = "contactConnected" TypeContactConnected = "contactConnected"
TypeChatCmdError = "chatCmdError" TypeChatCmdError = "chatCmdError"
@@ -46,10 +47,14 @@ func (e Event) Decode(v any) error {
type ( 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 struct { Profile Profile `json:"profile"`
DisplayName string `json:"displayName"` }
} `json:"profile"`
// Profile is how the bot or a contact presents itself. Nothing
// makes a display name unique.
Profile struct {
DisplayName string `json:"displayName"`
} }
// ConnLink is a SimpleX link. The short form is what people share; // ConnLink is a SimpleX link. The short form is what people share;
@@ -61,7 +66,9 @@ 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
@@ -142,6 +149,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 +204,10 @@ 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)
}
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,