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
218 lines
5.4 KiB
Go
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
|
|
}
|