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:
@@ -14,6 +14,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -21,15 +22,33 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// maxMessageSize bounds one message from the chat client. The largest
|
||||
// thing it sends is a record carrying a contact's profile picture, well
|
||||
// under this.
|
||||
// maxMessageSize bounds one message from the chat client; a larger one
|
||||
// ends the connection. The largest it sends are the pages of chat items
|
||||
// ChatItems asks for, which chatItemsPage keeps under this.
|
||||
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 (
|
||||
// ErrClosed is returned by Command once the connection has ended.
|
||||
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")
|
||||
errCommand = errors.New("command failed")
|
||||
)
|
||||
@@ -180,6 +199,43 @@ func (c *Client) Contacts(ctx context.Context, userID int64) ([]Contact, error)
|
||||
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
|
||||
// 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.
|
||||
@@ -196,6 +252,32 @@ func (c *Client) SendText(contactID, quotedItemID int64, text string) error {
|
||||
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
|
||||
// are the discriminators of its chatError record, such as "errorStore"
|
||||
// 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 {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user