Files
simplexcalc/internal/api/messages.go
T
clawbot cbcae80260
check / check (push) Successful in 1m2s
HTTP API: register, list and remove webhooks, kept across restarts (closes #6)
POST, GET and DELETE under /api/v1/chats/{id}/webhooks, for the chats
that GET /api/v1/chats lists. The webhooks are kept in
$DATA_DIR/webhooks.json, mode 0600, which each change replaces whole
through a temporary file in the same directory and a rename. bot.Run
reads the file before it starts the chat client: absent means none, and
a file that cannot be read aborts startup. Reading a JSON request body
moved into decodeBody, which the messages endpoint now shares. Nothing
is posted to a webhook yet.

Model: opus-5-5
2026-09-29 06:08:04 +00:00

189 lines
4.6 KiB
Go

package api
import (
"errors"
"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
}
var req request
if !h.decodeBody(w, r, &req, `{"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
}