From 2ad0b68e351a1ed5acdbaa503f313f93ace79362 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 03:48:43 +0000 Subject: [PATCH 1/4] HTTP API: a chat's recent messages, and sending a message (closes #5) GET /api/v1/chats/{id}/messages returns the messages among a chat's last count items (default 20, at most 100), oldest first. POST sends a text, waits for the chat client's answer and returns 201 with the message as sent. A chat the bot does not have is 404, a contact who deleted the chat is 409, a text too long for one message is 413, and any other failure is 500 with a chosen sentence. The chat client spells out a message's formatting in its answer, up to 26 times the text's length, so 100 items in one answer can pass the 16 MiB read limit and end the connection. Items are read five at a time. Model: opus-5-5 --- README.md | 84 +++++++- docs/TODO.md | 2 + internal/api/api.go | 15 +- internal/api/api_test.go | 33 +++- internal/api/messages.go | 217 +++++++++++++++++++++ internal/api/messages_test.go | 309 ++++++++++++++++++++++++++++++ internal/simplex/client.go | 101 +++++++++- internal/simplex/client_test.go | 15 +- internal/simplex/messages_test.go | 214 +++++++++++++++++++++ internal/simplex/protocol.go | 56 ++++-- 10 files changed, 1018 insertions(+), 28 deletions(-) create mode 100644 internal/api/messages.go create mode 100644 internal/api/messages_test.go create mode 100644 internal/simplex/messages_test.go diff --git a/README.md b/README.md index ea040d7..a38151d 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ variables that are absent. ## API An HTTP API beside the chat client lets another program read the bot's -chats. It speaks JSON on `PORT`. +chats and send messages in them. It speaks JSON on `PORT`. **Authentication.** Every request carries the credential from `API_TOKEN_FILE`: @@ -133,6 +133,88 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/api/v1/chats with the bot. The chat stays in the list, but nothing more reaches them. +### `GET /api/v1/chats/{id}/messages` + +The latest messages in the chat `id`, oldest first. `count`, a whole +number from 1 to 100 and 20 if absent, is how many of the chat's latest +items to read. The chat client also records events in a chat, such as +the contact connecting, and only messages are returned, so fewer than +`count` can come back. + +```sh +curl -H "Authorization: Bearer $TOKEN" \ + 'http://127.0.0.1:8080/api/v1/chats/3/messages?count=5' +``` + +```json +{ + "messages": [ + { + "id": 9, + "direction": "received", + "type": "text", + "text": "2 + 2", + "time": "2026-09-29T03:14:34Z" + }, + { + "id": 10, + "direction": "sent", + "type": "text", + "text": "4", + "time": "2026-09-29T03:14:35.101223457Z" + } + ] +} +``` + +A message has: + +- `id` — its number, unique across all the bot's chats. +- `direction` — `received` from the contact, or `sent` by the bot. +- `type` — `text`, or another kind of SimpleX message, such as `image`, + `file` or `voice`. +- `text` — the text; for a message that is not `text`, its caption, + which can be empty. +- `time` — in RFC 3339, in UTC: for a received message, when it reached + the SimpleX relay, to the second; for a sent one, when the bot sent + it. + +A `count` other than a whole number from 1 to 100 gets `400`, and an +`id` that is not one of the bot's chats gets `404`. + +### `POST /api/v1/chats/{id}/messages` + +Sends the `text` in the body to the chat `id`, and answers `201` with +the message as sent. The answer comes once the chat client has taken the +message, before it reaches the contact. + +```sh +curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"text":"hello"}' http://127.0.0.1:8080/api/v1/chats/3/messages +``` + +```json +{ + "message": { + "id": 12, + "direction": "sent", + "type": "text", + "text": "hello", + "time": "2026-09-29T03:14:43.519552587Z" + } +} +``` + +Nothing is sent, and the answer is: + +- `400` if the body is not JSON of that shape, or `text` is empty; +- `404` if `id` is not one of the bot's chats; +- `409` if the contact cannot receive messages: they have deleted their + chat with the bot (`contact_deleted` is `true`), or have not finished + connecting; +- `413` if the body is over 64 KiB, or the text is too long for one + SimpleX message, which holds about 15,000 bytes. + ## Entrypoints This repo adheres to the diff --git a/docs/TODO.md b/docs/TODO.md index 3b09904..7fa040e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -27,6 +27,8 @@ with no deprecation warning. # Completed Steps +- 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` diff --git a/internal/api/api.go b/internal/api/api.go index fbbe9db..c05bad9 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,5 +1,5 @@ // Package api is the bot's HTTP API, through which another program -// reads the bot's chats. +// reads the bot's chats and sends messages in them. // // Every request must carry the credential, as "Authorization: Bearer // {credential}"; with no credential configured, every request is @@ -47,6 +47,17 @@ const ( 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. @@ -86,6 +97,8 @@ func New(p Params) *http.Server { }) 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()) }) return &http.Server{ diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 2f78fe4..ad0e124 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -22,19 +22,24 @@ const ( bearer = "Bearer " + credential chatsPath = "/api/v1/chats" + messagesPath = "/api/v1/chats/3/messages" 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. +// items or sent, or with err, and remembers what it was asked. type fakeClient struct { contacts []simplex.Contact + items []simplex.ChatItem + sent simplex.ChatItem err error - userID int64 - hadDeadline bool + userID, contactID int64 + count int + text string + hadDeadline bool } func (f *fakeClient) Contacts( @@ -46,6 +51,24 @@ func (f *fakeClient) Contacts( return f.contacts, f.err } +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), @@ -168,6 +191,10 @@ func TestNoPathIsExempt(t *testing.T) { http.MethodPost, chatsPath, bearer, http.StatusMethodNotAllowed, `{"error":"method not allowed"}` + "\n", }, + { + http.MethodPost, messagesPath, "", + http.StatusUnauthorized, unauthorized, + }, } { rec := request(t, srv, tc.method, tc.path, tc.auth) if rec.Code != tc.want || rec.Body.String() != tc.body { diff --git a/internal/api/messages.go b/internal/api/messages.go new file mode 100644 index 0000000..a732b44 --- /dev/null +++ b/internal/api/messages.go @@ -0,0 +1,217 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "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 + + // noSuchChat answers a request naming a chat the bot does not have. + noSuchChat = "no such chat" +) + +// 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 := chatID(r) + if !ok { + h.respondError(w, http.StatusNotFound, noSuchChat) + + return + } + + count, ok := messageCount(r) + 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 := chatID(r) + if !ok { + h.respondError(w, http.StatusNotFound, noSuchChat) + + return + } + + 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 + } + + var req request + + if err != nil || json.Unmarshal(body, &req) != nil { + h.respondError(w, http.StatusBadRequest, + `the body must be JSON such as {"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 chat the bot does not have, 409 for a contact 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) + } +} + +// chatID returns the chat id in the request's path, and false if it is +// not one: a chat's id is its contact's, a positive whole number. +func chatID(r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + + return id, err == nil && id > 0 +} + +// messageCount returns the request's count, defaultCount if it has none, +// and false if it is anything but a whole number from 1 to maxCount. The +// query is parsed here because r.URL.Query drops a pair it cannot +// decode, which would turn count=1% into the default. +func messageCount(r *http.Request) (int, bool) { + query, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + return 0, false + } + + if !query.Has("count") { + return defaultCount, true + } + + count, err := strconv.Atoi(query.Get("count")) + + return count, err == nil && count >= 1 && count <= maxCount +} diff --git a/internal/api/messages_test.go b/internal/api/messages_test.go new file mode 100644 index 0000000..4349549 --- /dev/null +++ b/internal/api/messages_test.go @@ -0,0 +1,309 @@ +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" +) + +// 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{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{}), + 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 client is asked. +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, + "?count=1%": 0, + } { + t.Run(query, func(t *testing.T) { + t.Parallel() + + client := &fakeClient{} + 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) + } + }) + } +} + +// TestNoSuchChat: a chat id that is not a positive whole number gets 404 +// without asking the chat client, and so does one it has no contact +// for, whether reading messages or sending one. +func TestNoSuchChat(t *testing.T) { + t.Parallel() + + for _, id := range []string{"tester", "0", "-3", "3.5", "99999999999999999999"} { + client := &fakeClient{} + 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 client was asked", id) + } + } + + srv := newAPI(credential, &fakeClient{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) + } + } +} + +// 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{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{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{} + 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{err: simplex.ErrContactNotReady}, + http.StatusConflict, `{"error":"the contact cannot receive messages"}`, + }, + "text too long": { + &fakeClient{err: simplex.ErrMessageTooLarge}, + http.StatusRequestEntityTooLarge, `{"error":"the text is too long"}`, + }, + "anything else": { + &fakeClient{err: errChat}, + http.StatusInternalServerError, `{"error":"the message could not be sent"}`, + }, + "no message in the answer": { + &fakeClient{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) + } + }) + } +} diff --git a/internal/simplex/client.go b/internal/simplex/client.go index db12d59..c5b338f 100644 --- a/internal/simplex/client.go +++ b/internal/simplex/client.go @@ -14,6 +14,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strconv" "strings" "sync" @@ -21,15 +22,33 @@ import ( "github.com/gorilla/websocket" ) -// maxMessageSize bounds one message from the chat client. The largest -// thing it sends is a record carrying a contact's profile picture, well -// under this. +// maxMessageSize bounds one message from the chat client; a larger one +// ends the connection. The largest it sends are the pages of chat items +// ChatItems asks for, which chatItemsPage keeps under this. 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 ( // ErrClosed is returned by Command once the connection has ended. 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") errCommand = errors.New("command failed") ) @@ -180,6 +199,43 @@ func (c *Client) Contacts(ctx context.Context, userID int64) ([]Contact, error) 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 // 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. @@ -196,6 +252,32 @@ func (c *Client) SendText(contactID, quotedItemID int64, text string) error { 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 // are the discriminators of its chatError record, such as "errorStore" // and "userContactLinkNotFound". @@ -341,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 { var r cmdError @@ -359,7 +443,16 @@ func commandError(ev Event) error { } } - return e + 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 + } } // cmdName is a command without its arguments, for error messages: the diff --git a/internal/simplex/client_test.go b/internal/simplex/client_test.go index ce33fc3..8515bbf 100644 --- a/internal/simplex/client_test.go +++ b/internal/simplex/client_test.go @@ -60,9 +60,9 @@ const ( ) // 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 -// silent for a command it has no record for, and reports every command -// it receives on got. +// with the record in replies under the whole command or else under its +// first word, stays silent for a command it has no record for, and +// reports every command it receives on got. type fakeChat struct { replies map[string]string got chan string @@ -112,8 +112,13 @@ func (f *fakeChat) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.got <- cmd.Cmd - name, _, _ := strings.Cut(cmd.Cmd, " ") - if resp, ok := f.replies[name]; ok { + resp, ok := f.replies[cmd.Cmd] + if !ok { + name, _, _ := strings.Cut(cmd.Cmd, " ") + resp, ok = f.replies[name] + } + + if ok { f.send(cmd.CorrID, resp) } } diff --git a/internal/simplex/messages_test.go b/internal/simplex/messages_test.go new file mode 100644 index 0000000..75c3086 --- /dev/null +++ b/internal/simplex/messages_test.go @@ -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) + } + }) + } +} diff --git a/internal/simplex/protocol.go b/internal/simplex/protocol.go index 3251727..c3b6171 100644 --- a/internal/simplex/protocol.go +++ b/internal/simplex/protocol.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strconv" + "time" ) // Response and event types this package and the bot act on. The chat @@ -15,6 +16,7 @@ const ( TypeUserContactLinkCreated = "userContactLinkCreated" TypeUserContactLinkUpdated = "userContactLinkUpdated" TypeContactsList = "contactsList" + TypeAPIChat = "apiChat" TypeNewChatItems = "newChatItems" TypeContactConnected = "contactConnected" TypeChatCmdError = "chatCmdError" @@ -71,8 +73,9 @@ type ( Status string `json:"contactStatus"` } - // NewChatItems is the record of a newChatItems event: messages - // received, or sent from this profile elsewhere. + // NewChatItems is the record of a newChatItems event, messages + // received or sent from this profile elsewhere, and of the answer + // to sending a message. NewChatItems struct { ChatItems []AChatItem `json:"chatItems"` } @@ -82,25 +85,36 @@ type ( 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 { ChatInfo struct { Type string `json:"type"` Contact *Contact `json:"contact,omitempty"` } `json:"chatInfo"` - ChatItem struct { - ChatDir tagged `json:"chatDir"` - Meta struct { - ItemID int64 `json:"itemId"` - } `json:"meta"` - Content struct { - Type string `json:"type"` - MsgContent *MsgContent `json:"msgContent,omitempty"` - } `json:"content"` - } `json:"chatItem"` + ChatItem ChatItem `json:"chatItem"` } - // MsgContent is a message body. Only "text" is sent or read here. + // 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 { + ChatDir tagged `json:"chatDir"` + Meta struct { + 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"` + Content struct { + Type string `json:"type"` + MsgContent *MsgContent `json:"msgContent,omitempty"` + } `json:"content"` + } + + // 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 { Type string `json:"type"` Text string `json:"text"` @@ -208,6 +222,20 @@ 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) { b, err := json.Marshal([]composedMessage{{ QuotedItemID: quotedItemID, -- 2.54.0 From 2ce8da766a48d142f2934680e65990af2c64aa26 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 04:32:28 +0000 Subject: [PATCH 2/4] HTTP API: 404 for a chat id the list of chats leaves out (closes #5) The chat client keeps contact records that GET /api/v1/chats does not list, such as the bot's own profile (1 on a new profile) and a contact it creates itself (2), and reads or sends in them when asked. The message endpoints now look the id up, through chatID in internal/api/chats.go, in the same list the chats endpoint answers with, and answer 404 for any id not in it. The webhook endpoints can call chatID too. Model: opus-5-5 --- README.md | 4 +- internal/api/api_test.go | 16 +++--- internal/api/chats.go | 91 +++++++++++++++++++++++++++-------- internal/api/chats_test.go | 2 +- internal/api/messages.go | 26 ++-------- internal/api/messages_test.go | 70 ++++++++++++++++++++------- 6 files changed, 140 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index a38151d..11dceca 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ A message has: it. A `count` other than a whole number from 1 to 100 gets `400`, and an -`id` that is not one of the bot's chats gets `404`. +`id` that `GET /api/v1/chats` does not list gets `404`. ### `POST /api/v1/chats/{id}/messages` @@ -208,7 +208,7 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ Nothing is sent, and the answer is: - `400` if the body is not JSON of that shape, or `text` is empty; -- `404` if `id` is not one of the bot's chats; +- `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; diff --git a/internal/api/api_test.go b/internal/api/api_test.go index ad0e124..ece56ba 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -29,12 +29,14 @@ const ( 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 err, and remembers what it was asked. +// 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 - err error + contacts []simplex.Contact + items []simplex.ChatItem + sent simplex.ChatItem + contactsErr error + err error userID, contactID int64 count int @@ -48,7 +50,7 @@ func (f *fakeClient) Contacts( f.userID = userID _, f.hadDeadline = ctx.Deadline() - return f.contacts, f.err + return f.contacts, f.contactsErr } func (f *fakeClient) ChatItems( @@ -269,7 +271,7 @@ func TestHeaders(t *testing.T) { {&fakeClient{}, chatsPath, bearer}, {&fakeClient{}, chatsPath, ""}, {&fakeClient{}, "/nothing", bearer}, - {&fakeClient{err: errChat}, chatsPath, bearer}, + {&fakeClient{contactsErr: errChat}, chatsPath, bearer}, } { rec := request(t, newAPI(credential, tc.client), http.MethodGet, tc.path, tc.auth) diff --git a/internal/api/chats.go b/internal/api/chats.go index 10c70fe..9b8bce7 100644 --- a/internal/api/chats.go +++ b/internal/api/chats.go @@ -2,26 +2,55 @@ package api import ( "cmp" + "context" "net/http" "slices" + "strconv" + + "github.com/go-chi/chi/v5" ) -// 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"` +// 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) { - contacts, err := h.client.Contacts(r.Context(), h.userID) + chats, err := h.chats(r.Context()) if err != nil { h.log.Error("listing the chats", "error", err) h.respondError(w, http.StatusInternalServerError, @@ -30,17 +59,39 @@ func (h *handlers) handleChats() http.HandlerFunc { 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}) } } + +// 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 +} diff --git a/internal/api/chats_test.go b/internal/api/chats_test.go index 3cc6b9f..14f0c00 100644 --- a/internal/api/chats_test.go +++ b/internal/api/chats_test.go @@ -54,7 +54,7 @@ func TestNoChats(t *testing.T) { func TestChatsFailure(t *testing.T) { t.Parallel() - rec := request(t, newAPI(credential, &fakeClient{err: errChat}), + rec := request(t, newAPI(credential, &fakeClient{contactsErr: errChat}), http.MethodGet, chatsPath, bearer) want := `{"error":"the chats could not be read"}` + "\n" diff --git a/internal/api/messages.go b/internal/api/messages.go index a732b44..7b3ebe6 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -9,7 +9,6 @@ import ( "strconv" "time" - "github.com/go-chi/chi/v5" "sneak.berlin/go/simplexcalc/internal/simplex" ) @@ -18,9 +17,6 @@ const ( // request for its messages reads. defaultCount = 20 maxCount = 100 - - // noSuchChat answers a request naming a chat the bot does not have. - noSuchChat = "no such chat" ) // message is how the API shows a message, wherever it shows one. @@ -69,10 +65,8 @@ func (h *handlers) handleMessages() http.HandlerFunc { } return func(w http.ResponseWriter, r *http.Request) { - contactID, ok := chatID(r) + contactID, ok := h.chatID(w, r) if !ok { - h.respondError(w, http.StatusNotFound, noSuchChat) - return } @@ -116,10 +110,8 @@ func (h *handlers) handleSend() http.HandlerFunc { } return func(w http.ResponseWriter, r *http.Request) { - contactID, ok := chatID(r) + contactID, ok := h.chatID(w, r) if !ok { - h.respondError(w, http.StatusNotFound, noSuchChat) - return } @@ -170,9 +162,9 @@ func (h *handlers) handleSend() http.HandlerFunc { } // respondChatError answers a request the chat client did not serve: 404 -// for a chat the bot does not have, 409 for a contact who cannot receive -// messages, 413 for a text too long to send, and 500 with sentence for -// anything else, which is logged as what. +// 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, ) { @@ -189,14 +181,6 @@ func (h *handlers) respondChatError( } } -// chatID returns the chat id in the request's path, and false if it is -// not one: a chat's id is its contact's, a positive whole number. -func chatID(r *http.Request) (int64, bool) { - id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) - - return id, err == nil && id > 0 -} - // messageCount returns the request's count, defaultCount if it has none, // and false if it is anything but a whole number from 1 to maxCount. The // query is parsed here because r.URL.Query drops a pair it cannot diff --git a/internal/api/messages_test.go b/internal/api/messages_test.go index 4349549..e5aede9 100644 --- a/internal/api/messages_test.go +++ b/internal/api/messages_test.go @@ -17,6 +17,12 @@ const ( 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 { @@ -54,7 +60,7 @@ func post( func TestMessages(t *testing.T) { t.Parallel() - client := &fakeClient{items: []simplex.ChatItem{ + 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"}, @@ -91,7 +97,7 @@ func TestMessages(t *testing.T) { func TestNoMessages(t *testing.T) { t.Parallel() - rec := request(t, newAPI(credential, &fakeClient{}), + rec := request(t, newAPI(credential, &fakeClient{contacts: oneChat()}), http.MethodGet, messagesPath, bearer) want := `{"messages":[]}` + "\n" @@ -101,7 +107,7 @@ func TestNoMessages(t *testing.T) { } // TestMessagesCount: count is a whole number from 1 to 100, and 20 when -// absent. Anything else is refused before the chat client is asked. +// absent. Anything else is refused before the chat's items are read. func TestMessagesCount(t *testing.T) { t.Parallel() @@ -122,7 +128,7 @@ func TestMessagesCount(t *testing.T) { t.Run(query, func(t *testing.T) { t.Parallel() - client := &fakeClient{} + client := &fakeClient{contacts: oneChat()} rec := request(t, newAPI(credential, client), http.MethodGet, messagesPath+query, bearer) @@ -145,14 +151,18 @@ func TestMessagesCount(t *testing.T) { } } -// TestNoSuchChat: a chat id that is not a positive whole number gets 404 -// without asking the chat client, and so does one it has no contact -// for, whether reading messages or sending one. +// 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{"tester", "0", "-3", "3.5", "99999999999999999999"} { - client := &fakeClient{} + 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" @@ -167,11 +177,11 @@ func TestNoSuchChat(t *testing.T) { } if client.contactID != 0 { - t.Errorf("chat %q: the chat client was asked", id) + t.Errorf("chat %q: the chat was read or sent to", id) } } - srv := newAPI(credential, &fakeClient{err: simplex.ErrNoContact}) + srv := newAPI(credential, &fakeClient{contacts: oneChat(), err: simplex.ErrNoContact}) for _, rec := range []*httptest.ResponseRecorder{ request(t, srv, http.MethodGet, messagesPath, bearer), @@ -184,12 +194,36 @@ func TestNoSuchChat(t *testing.T) { } } +// 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{err: errChat}), + rec := request(t, newAPI(credential, &fakeClient{contacts: oneChat(), err: errChat}), http.MethodGet, messagesPath, bearer) want := `{"error":"the messages could not be read"}` + "\n" @@ -203,7 +237,7 @@ func TestMessagesFailure(t *testing.T) { func TestSend(t *testing.T) { t.Parallel() - client := &fakeClient{sent: chatItem(t, `{"meta":{"itemId":12, + 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"}}}`)} @@ -250,7 +284,7 @@ func TestSendBadBody(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - client := &fakeClient{} + client := &fakeClient{contacts: oneChat()} rec := post(t, newAPI(credential, client), messagesPath, tc.body) if rec.Code != tc.status || rec.Body.String() != tc.answer { @@ -277,19 +311,19 @@ func TestSendRefused(t *testing.T) { answer string }{ "contact deleted": { - &fakeClient{err: simplex.ErrContactNotReady}, + &fakeClient{contacts: oneChat(), err: simplex.ErrContactNotReady}, http.StatusConflict, `{"error":"the contact cannot receive messages"}`, }, "text too long": { - &fakeClient{err: simplex.ErrMessageTooLarge}, + &fakeClient{contacts: oneChat(), err: simplex.ErrMessageTooLarge}, http.StatusRequestEntityTooLarge, `{"error":"the text is too long"}`, }, "anything else": { - &fakeClient{err: errChat}, + &fakeClient{contacts: oneChat(), err: errChat}, http.StatusInternalServerError, `{"error":"the message could not be sent"}`, }, "no message in the answer": { - &fakeClient{sent: chatItem(t, `{"meta":{"itemId":13}, + &fakeClient{contacts: oneChat(), sent: chatItem(t, `{"meta":{"itemId":13}, "content":{"type":"sndDirectEvent"}}`)}, http.StatusInternalServerError, `{"error":"the chat client's answer could not be read"}`, -- 2.54.0 From 4824937c92f54d7c51cdd5dcb99f4726018df98d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 05:08:52 +0000 Subject: [PATCH 3/4] HTTP API: its own 400 for a query that cannot be read (closes #5) GET /api/v1/chats/{id}/messages answered any query that does not decode, such as one holding ; or %zz, with the sentence about count, even when count was valid or absent. It now answers such a query with "the query cannot be read", and keeps the count sentence for a count that decodes but is not a whole number from 1 to 100. The README names both cases. Model: opus-5-5 --- README.md | 6 ++++-- internal/api/messages.go | 24 +++++++++++++----------- internal/api/messages_test.go | 24 +++++++++++++++++++++++- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 11dceca..b84a68b 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,10 @@ A message has: the SimpleX relay, to the second; for a sent one, when the bot sent it. -A `count` other than a whole number from 1 to 100 gets `400`, and an -`id` that `GET /api/v1/chats` does not list gets `404`. +The answer is `400` if the query cannot be read (it holds a `;`, or a +`%` not followed by two hexadecimal digits) or `count` is not a whole +number from 1 to 100, and `404` if `GET /api/v1/chats` does not list +`id`. ### `POST /api/v1/chats/{id}/messages` diff --git a/internal/api/messages.go b/internal/api/messages.go index 7b3ebe6..8c0fb34 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -70,7 +70,16 @@ func (h *handlers) handleMessages() http.HandlerFunc { return } - count, ok := messageCount(r) + // 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)) @@ -181,16 +190,9 @@ func (h *handlers) respondChatError( } } -// messageCount returns the request's count, defaultCount if it has none, -// and false if it is anything but a whole number from 1 to maxCount. The -// query is parsed here because r.URL.Query drops a pair it cannot -// decode, which would turn count=1% into the default. -func messageCount(r *http.Request) (int, bool) { - query, err := url.ParseQuery(r.URL.RawQuery) - if err != nil { - return 0, false - } - +// 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 } diff --git a/internal/api/messages_test.go b/internal/api/messages_test.go index e5aede9..c56322b 100644 --- a/internal/api/messages_test.go +++ b/internal/api/messages_test.go @@ -123,7 +123,6 @@ func TestMessagesCount(t *testing.T) { "?count=2.5": 0, "?count=ten": 0, "?count=": 0, - "?count=1%": 0, } { t.Run(query, func(t *testing.T) { t.Parallel() @@ -151,6 +150,29 @@ func TestMessagesCount(t *testing.T) { } } +// TestMessagesUnreadableQuery: a query that cannot be decoded is refused +// with a sentence that says so, whether or not the bad part is count. +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", + } { + 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 -- 2.54.0 From 513d2a53531e42bbeadec3223e4658d4a32b44db Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 05:17:01 +0000 Subject: [PATCH 4/4] README: state the rule for a query that cannot be read (closes #5) The README listed the queries that GET /api/v1/chats/{id}/messages cannot read as if the list were complete, but a query of more than 10,000 parts separated by & is refused the same way, because Go's url.ParseQuery takes no more. The README now states the rule, that a query the server cannot read gets 400 with "the query cannot be read", and gives the three cases as examples. TestMessagesUnreadableQuery covers the 10,000-part case. Model: opus-5-5 --- README.md | 9 +++++---- internal/api/messages_test.go | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b84a68b..c7f8d06 100644 --- a/README.md +++ b/README.md @@ -179,10 +179,11 @@ A message has: the SimpleX relay, to the second; for a sent one, when the bot sent it. -The answer is `400` if the query cannot be read (it holds a `;`, or a -`%` not followed by two hexadecimal digits) or `count` is not a whole -number from 1 to 100, and `404` if `GET /api/v1/chats` does not list -`id`. +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` diff --git a/internal/api/messages_test.go b/internal/api/messages_test.go index c56322b..24d1de7 100644 --- a/internal/api/messages_test.go +++ b/internal/api/messages_test.go @@ -152,6 +152,7 @@ func TestMessagesCount(t *testing.T) { // 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() @@ -159,6 +160,7 @@ func TestMessagesUnreadableQuery(t *testing.T) { 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), -- 2.54.0