Files
simplexcalc/internal/api/messages.go
T
clawbot 2ad0b68e35
check / check (push) Successful in 1m21s
HTTP API: a chat's recent messages, and sending a message (closes #5)
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
2026-09-29 03:48:45 +00:00

218 lines
5.4 KiB
Go

package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"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
// noSuchChat answers a request naming a chat the bot does not have.
noSuchChat = "no such chat"
)
// 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 := chatID(r)
if !ok {
h.respondError(w, http.StatusNotFound, noSuchChat)
return
}
count, ok := messageCount(r)
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 := chatID(r)
if !ok {
h.respondError(w, http.StatusNotFound, noSuchChat)
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 chat the bot does not have, 409 for a contact 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)
}
}
// chatID returns the chat id in the request's path, and false if it is
// not one: a chat's id is its contact's, a positive whole number.
func chatID(r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
return id, err == nil && id > 0
}
// messageCount returns the request's count, defaultCount if it has none,
// and false if it is anything but a whole number from 1 to maxCount. The
// query is parsed here because r.URL.Query drops a pair it cannot
// decode, which would turn count=1% into the default.
func messageCount(r *http.Request) (int, bool) {
query, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
return 0, false
}
if !query.Has("count") {
return defaultCount, true
}
count, err := strconv.Atoi(query.Get("count"))
return count, err == nil && count >= 1 && count <= maxCount
}