From f53b666119f697b6a1993daceb2427e15bf29481 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 07:33:36 +0200 Subject: [PATCH] HTTP API: a chat's recent messages, and sending a message (closes #5) `GET /api/v1/chats/{id}/messages?count=N` returns a chat's recent messages, oldest first (`count` 1 to 100, default 20), and `POST` to the same path sends a text message and returns it once the chat client has taken it. Both answer `404` for any id the chats list does not show, looked up in the same contact list. The message record is defined once, for the webhooks to reuse. Disclosures: items are read five at a time, because one item can be many times its text and 100 at once could pass the 16 MiB read limit; text too long gets `413`, a contact who deleted the chat `409` (unverified for one still connecting); a query the server cannot read gets its own `400`. Model: opus-5-5 --- README.md | 87 ++++++- docs/TODO.md | 2 + internal/api/api.go | 15 +- internal/api/api_test.go | 43 +++- internal/api/chats.go | 91 ++++++-- internal/api/chats_test.go | 2 +- internal/api/messages.go | 203 +++++++++++++++++ internal/api/messages_test.go | 367 ++++++++++++++++++++++++++++++ internal/simplex/client.go | 101 +++++++- internal/simplex/client_test.go | 15 +- internal/simplex/messages_test.go | 214 +++++++++++++++++ internal/simplex/protocol.go | 56 +++-- 12 files changed, 1143 insertions(+), 53 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..c7f8d06 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,91 @@ 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. + +The answer is `400` if `count` is not a whole number from 1 to 100, and +`404` if `GET /api/v1/chats` does not list `id`. A query the server +cannot read gets `400` with `the query cannot be read`; examples are a +query that holds a `;`, a `%` not followed by two hexadecimal digits, or +more than 10,000 parts separated by `&`. + +### `POST /api/v1/chats/{id}/messages` + +Sends the `text` in the body to the chat `id`, and answers `201` with +the message as sent. The answer comes once the chat client has taken the +message, before it reaches the contact. + +```sh +curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"text":"hello"}' http://127.0.0.1:8080/api/v1/chats/3/messages +``` + +```json +{ + "message": { + "id": 12, + "direction": "sent", + "type": "text", + "text": "hello", + "time": "2026-09-29T03:14:43.519552587Z" + } +} +``` + +Nothing is sent, and the answer is: + +- `400` if the body is not JSON of that shape, or `text` is empty; +- `404` if `GET /api/v1/chats` does not list `id`; +- `409` if the contact cannot receive messages: they have deleted their + chat with the bot (`contact_deleted` is `true`), or have not finished + connecting; +- `413` if the body is over 64 KiB, or the text is too long for one + SimpleX message, which holds about 15,000 bytes. + ## 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..ece56ba 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -22,19 +22,26 @@ 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 an error: contactsErr from Contacts, err from +// the others. It remembers what it was asked. type fakeClient struct { - contacts []simplex.Contact - err error + contacts []simplex.Contact + items []simplex.ChatItem + sent simplex.ChatItem + contactsErr error + err error - userID int64 - hadDeadline bool + userID, contactID int64 + count int + text string + hadDeadline bool } func (f *fakeClient) Contacts( @@ -43,7 +50,25 @@ 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( + 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 { @@ -168,6 +193,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 { @@ -242,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 new file mode 100644 index 0000000..8c0fb34 --- /dev/null +++ b/internal/api/messages.go @@ -0,0 +1,203 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +const ( + // defaultCount and maxCount bound how many of a chat's last items a + // request for its messages reads. + defaultCount = 20 + maxCount = 100 +) + +// message is how the API shows a message, wherever it shows one. +type message struct { + ID int64 `json:"id"` + Direction string `json:"direction"` + Type string `json:"type"` + Text string `json:"text"` + Time time.Time `json:"time"` +} + +// newMessage returns the message a chat item holds, and false for an +// item that holds none: the chat client also records events in a chat, +// such as the contact connecting. +func newMessage(item simplex.ChatItem) (message, bool) { + var direction string + + switch item.Content.Type { + case "rcvMsgContent": + direction = "received" + case "sndMsgContent": + direction = "sent" + default: + return message{}, false + } + + content := item.Content.MsgContent + if content == nil { + return message{}, false + } + + return message{ + ID: item.Meta.ItemID, + Direction: direction, + Type: content.Type, + Text: content.Text, + Time: item.Meta.ItemTs, + }, true +} + +// handleMessages answers with the messages among a chat's last count +// items, oldest first. +func (h *handlers) handleMessages() http.HandlerFunc { + type response struct { + Messages []message `json:"messages"` + } + + return func(w http.ResponseWriter, r *http.Request) { + contactID, ok := h.chatID(w, r) + if !ok { + return + } + + // Not r.URL.Query, which drops a pair it cannot decode and so + // would turn count=1% into the default. + query, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + h.respondError(w, http.StatusBadRequest, "the query cannot be read") + + return + } + + count, ok := messageCount(query) + if !ok { + h.respondError(w, http.StatusBadRequest, + "count must be a whole number from 1 to "+strconv.Itoa(maxCount)) + + return + } + + items, err := h.client.ChatItems(r.Context(), contactID, count) + if err != nil { + h.respondChatError(w, err, "reading a chat's messages", + "the messages could not be read") + + return + } + + messages := make([]message, 0, len(items)) + + for _, item := range items { + if m, ok := newMessage(item); ok { + messages = append(messages, m) + } + } + + h.respond(w, http.StatusOK, response{Messages: messages}) + } +} + +// handleSend sends the text in the request's body to a chat, and answers +// with the message sent. +func (h *handlers) handleSend() http.HandlerFunc { + type request struct { + Text string `json:"text"` + } + + type response struct { + Message message `json:"message"` + } + + return func(w http.ResponseWriter, r *http.Request) { + contactID, ok := h.chatID(w, r) + if !ok { + return + } + + 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 contact removed after chatID found it, 409 for one who cannot +// receive messages, 413 for a text too long to send, and 500 with +// sentence for anything else, which is logged as what. +func (h *handlers) respondChatError( + w http.ResponseWriter, err error, what, sentence string, +) { + switch { + case errors.Is(err, simplex.ErrNoContact): + h.respondError(w, http.StatusNotFound, noSuchChat) + case errors.Is(err, simplex.ErrContactNotReady): + h.respondError(w, http.StatusConflict, "the contact cannot receive messages") + case errors.Is(err, simplex.ErrMessageTooLarge): + h.respondError(w, http.StatusRequestEntityTooLarge, "the text is too long") + default: + h.log.Error(what, "error", err) + h.respondError(w, http.StatusInternalServerError, sentence) + } +} + +// messageCount returns the query's count, defaultCount if it has none, +// and false if it is anything but a whole number from 1 to maxCount. +func messageCount(query url.Values) (int, bool) { + if !query.Has("count") { + return defaultCount, true + } + + count, err := strconv.Atoi(query.Get("count")) + + return count, err == nil && count >= 1 && count <= maxCount +} diff --git a/internal/api/messages_test.go b/internal/api/messages_test.go new file mode 100644 index 0000000..24d1de7 --- /dev/null +++ b/internal/api/messages_test.go @@ -0,0 +1,367 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +// Answers more than one test expects. +const ( + noSuchChat = `{"error":"no such chat"}` + "\n" + badCount = `{"error":"count must be a whole number from 1 to 100"}` + "\n" + notJSON = `{"error":"the body must be JSON such as {\"text\":\"hello\"}"}` + "\n" +) + +// oneChat returns the bot's contacts in these tests: one, whose chat is +// the one messagesPath names. +func oneChat() []simplex.Contact { + return []simplex.Contact{{ContactID: 3, Status: "active"}} +} + +// chatItem is a chat item decoded from a record shaped as the chat +// client sends it, reduced to the fields the API reads. +func chatItem(t *testing.T, record string) simplex.ChatItem { + t.Helper() + + var item simplex.ChatItem + + err := json.Unmarshal([]byte(record), &item) + if err != nil { + t.Fatalf("decoding %s: %v", record, err) + } + + return item +} + +// post sends srv a POST of body to path, with the credential. +func post( + t *testing.T, srv *http.Server, path, body string, +) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, path, + strings.NewReader(body)) + req.Header.Set("Authorization", bearer) + + rec := httptest.NewRecorder() + srv.Handler.ServeHTTP(rec, req) + + return rec +} + +// TestMessages: the messages among the chat's last 20 items come back +// oldest first, without the events the chat client records in a chat, +// and the chat client is asked with a deadline. +func TestMessages(t *testing.T) { + t.Parallel() + + client := &fakeClient{contacts: oneChat(), items: []simplex.ChatItem{ + chatItem(t, `{"meta":{"itemId":7,"itemTs":"2026-09-29T03:13:46.521359978Z"}, + "content":{"type":"rcvChatFeature","feature":"calls"}}`), + chatItem(t, `{"meta":{"itemId":9,"itemTs":"2026-09-29T03:14:34Z"}, + "content":{"type":"rcvMsgContent", + "msgContent":{"type":"text","text":"2 + 2"}}}`), + chatItem(t, `{"meta":{"itemId":10,"itemTs":"2026-09-29T03:14:35.101Z"}, + "content":{"type":"sndMsgContent","msgContent":{"type":"text","text":"4"}}}`), + chatItem(t, `{"meta":{"itemId":11,"itemTs":"2026-09-29T03:15:02Z"}, + "content":{"type":"rcvMsgContent","msgContent":{"type":"image", + "text":"a picture","image":"data:image/jpg;base64,/9j/4AAQ"}}}`), + }} + + rec := request(t, newAPI(credential, client), http.MethodGet, messagesPath, bearer) + + want := `{"messages":[` + + `{"id":9,"direction":"received","type":"text","text":"2 + 2",` + + `"time":"2026-09-29T03:14:34Z"},` + + `{"id":10,"direction":"sent","type":"text","text":"4",` + + `"time":"2026-09-29T03:14:35.101Z"},` + + `{"id":11,"direction":"received","type":"image","text":"a picture",` + + `"time":"2026-09-29T03:15:02Z"}]}` + "\n" + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want) + } + + if client.contactID != 3 || client.count != 20 || !client.hadDeadline { + t.Errorf("the chat client was asked for %d items of chat %d, deadline %v; "+ + "want 20 of chat 3 with a deadline", + client.count, client.contactID, client.hadDeadline) + } +} + +// TestNoMessages: a chat without messages is an empty list, not null. +func TestNoMessages(t *testing.T) { + t.Parallel() + + rec := request(t, newAPI(credential, &fakeClient{contacts: oneChat()}), + http.MethodGet, messagesPath, bearer) + + want := `{"messages":[]}` + "\n" + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want) + } +} + +// TestMessagesCount: count is a whole number from 1 to 100, and 20 when +// absent. Anything else is refused before the chat's items are read. +func TestMessagesCount(t *testing.T) { + t.Parallel() + + // The number of items the chat client is asked for; 0 for refused. + for query, want := range map[string]int{ + "": 20, + "?count=1": 1, + "?count=100": 100, + "?count=%35": 5, + "?count=0": 0, + "?count=101": 0, + "?count=-1": 0, + "?count=2.5": 0, + "?count=ten": 0, + "?count=": 0, + } { + t.Run(query, func(t *testing.T) { + t.Parallel() + + client := &fakeClient{contacts: oneChat()} + rec := request(t, newAPI(credential, client), + http.MethodGet, messagesPath+query, bearer) + + if want == 0 { + if rec.Code != http.StatusBadRequest || rec.Body.String() != badCount || + client.count != 0 { + t.Errorf("response = %d %q, asked for %d items; "+ + "want 400 %q and nothing asked", rec.Code, rec.Body.String(), + client.count, badCount) + } + + return + } + + if rec.Code != http.StatusOK || client.count != want { + t.Errorf("response = %d, asked for %d items; want 200 and %d", + rec.Code, client.count, want) + } + }) + } +} + +// TestMessagesUnreadableQuery: a query that cannot be decoded is refused +// with a sentence that says so, whether or not the bad part is count. +// The last query has 10,001 parts, more than Go's url.ParseQuery takes. +func TestMessagesUnreadableQuery(t *testing.T) { + t.Parallel() + + want := `{"error":"the query cannot be read"}` + "\n" + + for _, query := range []string{ + "?count=1%", "?count=5&x=%zz", "?x=%zz", "?count=5;x=1", + "?count=5" + strings.Repeat("&", 10000), + } { + client := &fakeClient{contacts: oneChat()} + rec := request(t, newAPI(credential, client), + http.MethodGet, messagesPath+query, bearer) + + if rec.Code != http.StatusBadRequest || rec.Body.String() != want || + client.count != 0 { + t.Errorf("%s: response = %d %q, asked for %d items; "+ + "want 400 %q and nothing asked", + query, rec.Code, rec.Body.String(), client.count, want) + } + } +} + +// TestNoSuchChat: an id that GET /api/v1/chats does not list gets 404, +// whether reading messages or sending one, and nothing is read or sent. +// That includes 1 and 2, contact records the chat client keeps on a new +// profile and would read or send in. A contact the chat client no +// longer has when asked also gets 404. +func TestNoSuchChat(t *testing.T) { + t.Parallel() + + for _, id := range []string{ + "1", "2", "4", "tester", "0", "-3", "3.5", "99999999999999999999", + } { + client := &fakeClient{contacts: oneChat()} + srv := newAPI(credential, client) + path := "/api/v1/chats/" + id + "/messages" + + for _, rec := range []*httptest.ResponseRecorder{ + request(t, srv, http.MethodGet, path, bearer), + post(t, srv, path, `{"text":"hello"}`), + } { + if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchChat { + t.Errorf("chat %q: response = %d %q, want 404 %q", + id, rec.Code, rec.Body.String(), noSuchChat) + } + } + + if client.contactID != 0 { + t.Errorf("chat %q: the chat was read or sent to", id) + } + } + + srv := newAPI(credential, &fakeClient{contacts: oneChat(), err: simplex.ErrNoContact}) + + for _, rec := range []*httptest.ResponseRecorder{ + request(t, srv, http.MethodGet, messagesPath, bearer), + post(t, srv, messagesPath, `{"text":"hello"}`), + } { + if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchChat { + t.Errorf("no contact: response = %d %q, want 404 %q", + rec.Code, rec.Body.String(), noSuchChat) + } + } +} + +// TestChatLookupFailure: when the chats cannot be read to look the id +// up, the answer is 500 and nothing is read or sent. +func TestChatLookupFailure(t *testing.T) { + t.Parallel() + + client := &fakeClient{contactsErr: errChat} + srv := newAPI(credential, client) + + want := `{"error":"the chats could not be read"}` + "\n" + + for _, rec := range []*httptest.ResponseRecorder{ + request(t, srv, http.MethodGet, messagesPath, bearer), + post(t, srv, messagesPath, `{"text":"hello"}`), + } { + if rec.Code != http.StatusInternalServerError || rec.Body.String() != want { + t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want) + } + } + + if client.contactID != 0 { + t.Error("the chat was read or sent to") + } +} + +// TestMessagesFailure: when the chat client fails, the answer says so in +// a chosen sentence, never in the error's own text. +func TestMessagesFailure(t *testing.T) { + t.Parallel() + + rec := request(t, newAPI(credential, &fakeClient{contacts: oneChat(), err: errChat}), + http.MethodGet, messagesPath, bearer) + + want := `{"error":"the messages could not be read"}` + "\n" + if rec.Code != http.StatusInternalServerError || rec.Body.String() != want { + t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want) + } +} + +// TestSend: the text goes to the chat's contact, with a deadline, and +// the answer is 201 with the message as sent. +func TestSend(t *testing.T) { + t.Parallel() + + client := &fakeClient{contacts: oneChat(), sent: chatItem(t, `{"meta":{"itemId":12, + "itemTs":"2026-09-29T03:14:43.519552587Z"},"content":{"type":"sndMsgContent", + "msgContent":{"type":"text","text":"hello"}}}`)} + + rec := post(t, newAPI(credential, client), messagesPath, `{"text":"hello"}`) + + want := `{"message":{"id":12,"direction":"sent","type":"text","text":"hello",` + + `"time":"2026-09-29T03:14:43.519552587Z"}}` + "\n" + if rec.Code != http.StatusCreated || rec.Body.String() != want { + t.Errorf("response = %d %q, want 201 %q", rec.Code, rec.Body.String(), want) + } + + if client.contactID != 3 || client.text != "hello" || !client.hadDeadline { + t.Errorf("the chat client was asked to send %q to chat %d, deadline %v; "+ + `want "hello" to chat 3 with a deadline`, + client.text, client.contactID, client.hadDeadline) + } +} + +// TestSendBadBody: a body that is not JSON with a text, or that is over +// 64 KiB, is refused before anything is sent. +func TestSendBadBody(t *testing.T) { + t.Parallel() + + textEmpty := `{"error":"text is empty"}` + "\n" + tooLarge := `{"error":"the body is too large"}` + "\n" + + for name, tc := range map[string]struct { + body string + status int + answer string + }{ + "empty": {"", http.StatusBadRequest, notJSON}, + "not JSON": {"hello", http.StatusBadRequest, notJSON}, + "not an object": {`["hello"]`, http.StatusBadRequest, notJSON}, + "text not a string": {`{"text":5}`, http.StatusBadRequest, notJSON}, + "more after it": {`{"text":"hello"} {}`, http.StatusBadRequest, notJSON}, + "no text": {`{}`, http.StatusBadRequest, textEmpty}, + "empty text": {`{"text":""}`, http.StatusBadRequest, textEmpty}, + "over 64 KiB": { + `{"text":"` + strings.Repeat("a", 64<<10) + `"}`, + http.StatusRequestEntityTooLarge, tooLarge, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + client := &fakeClient{contacts: oneChat()} + rec := post(t, newAPI(credential, client), messagesPath, tc.body) + + if rec.Code != tc.status || rec.Body.String() != tc.answer { + t.Errorf("response = %d %q, want %d %q", + rec.Code, rec.Body.String(), tc.status, tc.answer) + } + + if client.contactID != 0 { + t.Error("the chat client was asked to send") + } + }) + } +} + +// TestSendRefused: a send the chat client refuses, or answers with no +// message, gets an answer chosen for the reason, never the chat +// client's own words. +func TestSendRefused(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + client *fakeClient + status int + answer string + }{ + "contact deleted": { + &fakeClient{contacts: oneChat(), err: simplex.ErrContactNotReady}, + http.StatusConflict, `{"error":"the contact cannot receive messages"}`, + }, + "text too long": { + &fakeClient{contacts: oneChat(), err: simplex.ErrMessageTooLarge}, + http.StatusRequestEntityTooLarge, `{"error":"the text is too long"}`, + }, + "anything else": { + &fakeClient{contacts: oneChat(), err: errChat}, + http.StatusInternalServerError, `{"error":"the message could not be sent"}`, + }, + "no message in the answer": { + &fakeClient{contacts: oneChat(), sent: chatItem(t, `{"meta":{"itemId":13}, + "content":{"type":"sndDirectEvent"}}`)}, + http.StatusInternalServerError, + `{"error":"the chat client's answer could not be read"}`, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + rec := post(t, newAPI(credential, tc.client), messagesPath, `{"text":"hello"}`) + + if rec.Code != tc.status || rec.Body.String() != tc.answer+"\n" { + t.Errorf("response = %d %q, want %d %q", + rec.Code, rec.Body.String(), tc.status, tc.answer) + } + }) + } +} 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,