HTTP API: a chat's recent messages, and sending a message (closes #5)
check / check (push) Successful in 1m21s
check / check (push) Successful in 1m21s
GET /api/v1/chats/{id}/messages returns the messages among a chat's
last count items (default 20, at most 100), oldest first. POST sends a
text, waits for the chat client's answer and returns 201 with the
message as sent. A chat the bot does not have is 404, a contact who
deleted the chat is 409, a text too long for one message is 413, and
any other failure is 500 with a chosen sentence.
The chat client spells out a message's formatting in its answer, up to
26 times the text's length, so 100 items in one answer can pass the
16 MiB read limit and end the connection. Items are read five at a
time.
Model: opus-5-5
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user