HTTP API: a chat's recent messages, and sending a message (closes #5)
check / check (push) Successful in 1m5s
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user