HTTP API: a chat's recent messages, and sending a message (closes #5)
check / check (push) Successful in 1m5s

`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
This commit was merged in pull request #15.
This commit is contained in:
2026-09-29 07:33:36 +02:00
parent 8f0906d677
commit f53b666119
12 changed files with 1143 additions and 53 deletions
+97 -4
View File
@@ -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
+10 -5
View File
@@ -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)
}
}
+214
View File
@@ -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)
}
})
}
}
+42 -14
View File
@@ -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,