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,