HTTP API: a chat's recent messages, and sending a message #15
@@ -89,7 +89,7 @@ variables that are absent.
|
|||||||
## API
|
## API
|
||||||
|
|
||||||
An HTTP API beside the chat client lets another program read the bot's
|
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
|
**Authentication.** Every request carries the credential from
|
||||||
`API_TOKEN_FILE`:
|
`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
|
with the bot. The chat stays in the list, but nothing more reaches
|
||||||
them.
|
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
|
## Entrypoints
|
||||||
|
|
||||||
This repo adheres to the
|
This repo adheres to the
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ with no deprecation warning.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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`,
|
- 2026-09-29 Added the HTTP API in `internal/api`: the server on `PORT`,
|
||||||
the bearer credential from `API_TOKEN_FILE`, security headers and
|
the bearer credential from `API_TOKEN_FILE`, security headers and
|
||||||
limits, and `GET /api/v1/chats`
|
limits, and `GET /api/v1/chats`
|
||||||
|
|||||||
+14
-1
@@ -1,5 +1,5 @@
|
|||||||
// Package api is the bot's HTTP API, through which another program
|
// 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
|
// Every request must carry the credential, as "Authorization: Bearer
|
||||||
// {credential}"; with no credential configured, every request is
|
// {credential}"; with no credential configured, every request is
|
||||||
@@ -47,6 +47,17 @@ const (
|
|||||||
type ChatClient interface {
|
type ChatClient interface {
|
||||||
// Contacts returns the contacts of the user userID.
|
// Contacts returns the contacts of the user userID.
|
||||||
Contacts(ctx context.Context, userID int64) ([]simplex.Contact, error)
|
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.
|
// Params configures New.
|
||||||
@@ -86,6 +97,8 @@ func New(p Params) *http.Server {
|
|||||||
})
|
})
|
||||||
router.Route("/api/v1", func(r chi.Router) {
|
router.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Get("/chats", h.handleChats())
|
r.Get("/chats", h.handleChats())
|
||||||
|
r.Get("/chats/{id}/messages", h.handleMessages())
|
||||||
|
r.Post("/chats/{id}/messages", h.handleSend())
|
||||||
})
|
})
|
||||||
|
|
||||||
return &http.Server{
|
return &http.Server{
|
||||||
|
|||||||
@@ -22,19 +22,26 @@ const (
|
|||||||
bearer = "Bearer " + credential
|
bearer = "Bearer " + credential
|
||||||
|
|
||||||
chatsPath = "/api/v1/chats"
|
chatsPath = "/api/v1/chats"
|
||||||
|
messagesPath = "/api/v1/chats/3/messages"
|
||||||
unauthorized = `{"error":"unauthorized"}` + "\n"
|
unauthorized = `{"error":"unauthorized"}` + "\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errChat = errors.New("sqlite: database is locked at /var/lib/simplexcalc")
|
var errChat = errors.New("sqlite: database is locked at /var/lib/simplexcalc")
|
||||||
|
|
||||||
// fakeClient stands in for the chat client. It answers with contacts,
|
// 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 {
|
type fakeClient struct {
|
||||||
contacts []simplex.Contact
|
contacts []simplex.Contact
|
||||||
err error
|
items []simplex.ChatItem
|
||||||
|
sent simplex.ChatItem
|
||||||
|
contactsErr error
|
||||||
|
err error
|
||||||
|
|
||||||
userID int64
|
userID, contactID int64
|
||||||
hadDeadline bool
|
count int
|
||||||
|
text string
|
||||||
|
hadDeadline bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeClient) Contacts(
|
func (f *fakeClient) Contacts(
|
||||||
@@ -43,7 +50,25 @@ func (f *fakeClient) Contacts(
|
|||||||
f.userID = userID
|
f.userID = userID
|
||||||
_, f.hadDeadline = ctx.Deadline()
|
_, 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 {
|
func newAPI(token string, client api.ChatClient) *http.Server {
|
||||||
@@ -168,6 +193,10 @@ func TestNoPathIsExempt(t *testing.T) {
|
|||||||
http.MethodPost, chatsPath, bearer,
|
http.MethodPost, chatsPath, bearer,
|
||||||
http.StatusMethodNotAllowed, `{"error":"method not allowed"}` + "\n",
|
http.StatusMethodNotAllowed, `{"error":"method not allowed"}` + "\n",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
http.MethodPost, messagesPath, "",
|
||||||
|
http.StatusUnauthorized, unauthorized,
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
rec := request(t, srv, tc.method, tc.path, tc.auth)
|
rec := request(t, srv, tc.method, tc.path, tc.auth)
|
||||||
if rec.Code != tc.want || rec.Body.String() != tc.body {
|
if rec.Code != tc.want || rec.Body.String() != tc.body {
|
||||||
@@ -242,7 +271,7 @@ func TestHeaders(t *testing.T) {
|
|||||||
{&fakeClient{}, chatsPath, bearer},
|
{&fakeClient{}, chatsPath, bearer},
|
||||||
{&fakeClient{}, chatsPath, ""},
|
{&fakeClient{}, chatsPath, ""},
|
||||||
{&fakeClient{}, "/nothing", bearer},
|
{&fakeClient{}, "/nothing", bearer},
|
||||||
{&fakeClient{err: errChat}, chatsPath, bearer},
|
{&fakeClient{contactsErr: errChat}, chatsPath, bearer},
|
||||||
} {
|
} {
|
||||||
rec := request(t, newAPI(credential, tc.client),
|
rec := request(t, newAPI(credential, tc.client),
|
||||||
http.MethodGet, tc.path, tc.auth)
|
http.MethodGet, tc.path, tc.auth)
|
||||||
|
|||||||
+71
-20
@@ -2,26 +2,55 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleChats lists the bot's chats, ordered by id. The bot talks to
|
// noSuchChat answers a request naming a chat the bot does not have.
|
||||||
// people only one to one, so its chats are its contacts, and a chat's
|
const noSuchChat = "no such chat"
|
||||||
// id is its contact's.
|
|
||||||
func (h *handlers) handleChats() http.HandlerFunc {
|
// chat is how the API shows a chat. The bot talks to people only one to
|
||||||
type chat struct {
|
// one, so its chats are its contacts, and a chat's id is its contact's.
|
||||||
ID int64 `json:"id"`
|
type chat struct {
|
||||||
DisplayName string `json:"display_name"`
|
ID int64 `json:"id"`
|
||||||
ContactDeleted bool `json:"contact_deleted"`
|
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 {
|
type response struct {
|
||||||
Chats []chat `json:"chats"`
|
Chats []chat `json:"chats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err != nil {
|
||||||
h.log.Error("listing the chats", "error", err)
|
h.log.Error("listing the chats", "error", err)
|
||||||
h.respondError(w, http.StatusInternalServerError,
|
h.respondError(w, http.StatusInternalServerError,
|
||||||
@@ -30,17 +59,39 @@ func (h *handlers) handleChats() http.HandlerFunc {
|
|||||||
return
|
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})
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestNoChats(t *testing.T) {
|
|||||||
func TestChatsFailure(t *testing.T) {
|
func TestChatsFailure(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
rec := request(t, newAPI(credential, &fakeClient{err: errChat}),
|
rec := request(t, newAPI(credential, &fakeClient{contactsErr: errChat}),
|
||||||
http.MethodGet, chatsPath, bearer)
|
http.MethodGet, chatsPath, bearer)
|
||||||
|
|
||||||
want := `{"error":"the chats could not be read"}` + "\n"
|
want := `{"error":"the chats could not be read"}` + "\n"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -21,15 +22,33 @@ import (
|
|||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxMessageSize bounds one message from the chat client. The largest
|
// maxMessageSize bounds one message from the chat client; a larger one
|
||||||
// thing it sends is a record carrying a contact's profile picture, well
|
// ends the connection. The largest it sends are the pages of chat items
|
||||||
// under this.
|
// ChatItems asks for, which chatItemsPage keeps under this.
|
||||||
const maxMessageSize = 16 << 20
|
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 (
|
var (
|
||||||
// ErrClosed is returned by Command once the connection has ended.
|
// ErrClosed is returned by Command once the connection has ended.
|
||||||
ErrClosed = errors.New("connection to the chat client closed")
|
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")
|
errUnexpected = errors.New("unexpected response")
|
||||||
errCommand = errors.New("command failed")
|
errCommand = errors.New("command failed")
|
||||||
)
|
)
|
||||||
@@ -180,6 +199,43 @@ func (c *Client) Contacts(ctx context.Context, userID int64) ([]Contact, error)
|
|||||||
return r.Contacts, err
|
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
|
// 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
|
// 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.
|
// 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)
|
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
|
// CommandError is a command the chat client refused. Type and Detail
|
||||||
// are the discriminators of its chatError record, such as "errorStore"
|
// are the discriminators of its chatError record, such as "errorStore"
|
||||||
// and "userContactLinkNotFound".
|
// 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 {
|
func commandError(ev Event) error {
|
||||||
var r cmdError
|
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
|
// 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
|
// 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
|
// with the record in replies under the whole command or else under its
|
||||||
// silent for a command it has no record for, and reports every command
|
// first word, stays silent for a command it has no record for, and
|
||||||
// it receives on got.
|
// reports every command it receives on got.
|
||||||
type fakeChat struct {
|
type fakeChat struct {
|
||||||
replies map[string]string
|
replies map[string]string
|
||||||
got chan string
|
got chan string
|
||||||
@@ -112,8 +112,13 @@ func (f *fakeChat) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
f.got <- cmd.Cmd
|
f.got <- cmd.Cmd
|
||||||
|
|
||||||
name, _, _ := strings.Cut(cmd.Cmd, " ")
|
resp, ok := f.replies[cmd.Cmd]
|
||||||
if resp, ok := f.replies[name]; ok {
|
if !ok {
|
||||||
|
name, _, _ := strings.Cut(cmd.Cmd, " ")
|
||||||
|
resp, ok = f.replies[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
f.send(cmd.CorrID, resp)
|
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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Response and event types this package and the bot act on. The chat
|
// Response and event types this package and the bot act on. The chat
|
||||||
@@ -15,6 +16,7 @@ const (
|
|||||||
TypeUserContactLinkCreated = "userContactLinkCreated"
|
TypeUserContactLinkCreated = "userContactLinkCreated"
|
||||||
TypeUserContactLinkUpdated = "userContactLinkUpdated"
|
TypeUserContactLinkUpdated = "userContactLinkUpdated"
|
||||||
TypeContactsList = "contactsList"
|
TypeContactsList = "contactsList"
|
||||||
|
TypeAPIChat = "apiChat"
|
||||||
TypeNewChatItems = "newChatItems"
|
TypeNewChatItems = "newChatItems"
|
||||||
TypeContactConnected = "contactConnected"
|
TypeContactConnected = "contactConnected"
|
||||||
TypeChatCmdError = "chatCmdError"
|
TypeChatCmdError = "chatCmdError"
|
||||||
@@ -71,8 +73,9 @@ type (
|
|||||||
Status string `json:"contactStatus"`
|
Status string `json:"contactStatus"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChatItems is the record of a newChatItems event: messages
|
// NewChatItems is the record of a newChatItems event, messages
|
||||||
// received, or sent from this profile elsewhere.
|
// received or sent from this profile elsewhere, and of the answer
|
||||||
|
// to sending a message.
|
||||||
NewChatItems struct {
|
NewChatItems struct {
|
||||||
ChatItems []AChatItem `json:"chatItems"`
|
ChatItems []AChatItem `json:"chatItems"`
|
||||||
}
|
}
|
||||||
@@ -82,25 +85,36 @@ type (
|
|||||||
Contact Contact `json:"contact"`
|
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 {
|
AChatItem struct {
|
||||||
ChatInfo struct {
|
ChatInfo struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Contact *Contact `json:"contact,omitempty"`
|
Contact *Contact `json:"contact,omitempty"`
|
||||||
} `json:"chatInfo"`
|
} `json:"chatInfo"`
|
||||||
ChatItem struct {
|
ChatItem ChatItem `json:"chatItem"`
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
MsgContent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
@@ -208,6 +222,20 @@ func cmdListContacts(userID int64) string {
|
|||||||
return "/_contacts " + strconv.FormatInt(userID, 10)
|
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) {
|
func cmdSendText(contactID, quotedItemID int64, text string) (string, error) {
|
||||||
b, err := json.Marshal([]composedMessage{{
|
b, err := json.Marshal([]composedMessage{{
|
||||||
QuotedItemID: quotedItemID,
|
QuotedItemID: quotedItemID,
|
||||||
|
|||||||
Reference in New Issue
Block a user