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
251 lines
7.4 KiB
Go
251 lines
7.4 KiB
Go
package simplex
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// Response and event types this package and the bot act on. The chat
|
|
// client sends many more; every other type is ignored, as its API
|
|
// documentation requires of clients.
|
|
const (
|
|
TypeActiveUser = "activeUser"
|
|
TypeUserContactLink = "userContactLink"
|
|
TypeUserContactLinkCreated = "userContactLinkCreated"
|
|
TypeUserContactLinkUpdated = "userContactLinkUpdated"
|
|
TypeContactsList = "contactsList"
|
|
TypeAPIChat = "apiChat"
|
|
TypeNewChatItems = "newChatItems"
|
|
TypeContactConnected = "contactConnected"
|
|
TypeChatCmdError = "chatCmdError"
|
|
)
|
|
|
|
// Event is one message from the chat client: a response to a command,
|
|
// or an event it sends unprompted. The protocol is a discriminated
|
|
// union on "type"; the rest of the record is decoded on demand, into a
|
|
// struct declaring only the fields the caller reads, so a record whose
|
|
// other fields changed shape between releases still decodes.
|
|
type Event struct {
|
|
Type string
|
|
raw json.RawMessage
|
|
}
|
|
|
|
// Decode unmarshals the whole record into v.
|
|
func (e Event) Decode(v any) error {
|
|
err := json.Unmarshal(e.raw, v)
|
|
if err != nil {
|
|
return fmt.Errorf("decoding %s: %w", e.Type, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Wire types, reduced to the fields this program uses. The field names
|
|
// are the chat client's, hence camelCase in the tags.
|
|
//
|
|
//nolint:tagliatelle // the chat client's wire format, not ours to name.
|
|
type (
|
|
// User is the chat client's local user profile: the bot itself.
|
|
User struct {
|
|
UserID int64 `json:"userId"`
|
|
Profile Profile `json:"profile"`
|
|
}
|
|
|
|
// Profile is how the bot or a contact presents itself. Nothing
|
|
// makes a display name unique.
|
|
Profile struct {
|
|
DisplayName string `json:"displayName"`
|
|
}
|
|
|
|
// ConnLink is a SimpleX link. The short form is what people share;
|
|
// the full form is what older clients understand.
|
|
ConnLink struct {
|
|
FullLink string `json:"connFullLink"`
|
|
ShortLink string `json:"connShortLink,omitempty"`
|
|
}
|
|
|
|
// Contact is a person connected to the bot.
|
|
Contact struct {
|
|
ContactID int64 `json:"contactId"`
|
|
Profile Profile `json:"profile"`
|
|
Status string `json:"contactStatus"`
|
|
}
|
|
|
|
// NewChatItems is the record of a newChatItems event, messages
|
|
// received or sent from this profile elsewhere, and of the answer
|
|
// to sending a message.
|
|
NewChatItems struct {
|
|
ChatItems []AChatItem `json:"chatItems"`
|
|
}
|
|
|
|
// ContactConnected is the record of a contactConnected event.
|
|
ContactConnected struct {
|
|
Contact Contact `json:"contact"`
|
|
}
|
|
|
|
// AChatItem is one chat item together with the chat it belongs to.
|
|
AChatItem struct {
|
|
ChatInfo struct {
|
|
Type string `json:"type"`
|
|
Contact *Contact `json:"contact,omitempty"`
|
|
} `json:"chatInfo"`
|
|
ChatItem ChatItem `json:"chatItem"`
|
|
}
|
|
|
|
// ChatItem is one item in a chat: a message, or an event the chat
|
|
// client records there, such as the contact connecting. Content.Type
|
|
// tells them apart: "rcvMsgContent" and "sndMsgContent" are messages
|
|
// received and sent.
|
|
ChatItem struct {
|
|
ChatDir tagged `json:"chatDir"`
|
|
Meta struct {
|
|
ItemID int64 `json:"itemId"`
|
|
// ItemTs is when a received message reached the SimpleX
|
|
// relay, and when a sent one was sent.
|
|
ItemTs time.Time `json:"itemTs"`
|
|
} `json:"meta"`
|
|
Content struct {
|
|
Type string `json:"type"`
|
|
MsgContent *MsgContent `json:"msgContent,omitempty"`
|
|
} `json:"content"`
|
|
}
|
|
|
|
// MsgContent is a message body: its type, such as "text", "image"
|
|
// or "file", and its text, which for anything but "text" is the
|
|
// caption. Only "text" is sent here.
|
|
MsgContent struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// AddressSettings configures the bot's long-term address.
|
|
AddressSettings struct {
|
|
BusinessAddress bool `json:"businessAddress"`
|
|
AutoAccept *AutoAccept `json:"autoAccept,omitempty"`
|
|
AutoReply *MsgContent `json:"autoReply,omitempty"`
|
|
}
|
|
|
|
// AutoAccept makes the chat client accept every contact request
|
|
// to the address itself, when present in AddressSettings.
|
|
AutoAccept struct {
|
|
AcceptIncognito bool `json:"acceptIncognito"`
|
|
}
|
|
|
|
composedMessage struct {
|
|
QuotedItemID int64 `json:"quotedItemId,omitempty"`
|
|
MsgContent MsgContent `json:"msgContent"`
|
|
Mentions map[string]int64 `json:"mentions"`
|
|
}
|
|
|
|
envelope struct {
|
|
CorrID string `json:"corrId,omitempty"`
|
|
Resp json.RawMessage `json:"resp"`
|
|
}
|
|
|
|
command struct {
|
|
CorrID string `json:"corrId"`
|
|
Cmd string `json:"cmd"`
|
|
}
|
|
|
|
cmdError struct {
|
|
ChatError struct {
|
|
Type string `json:"type"`
|
|
ErrorType *tagged `json:"errorType,omitempty"`
|
|
StoreError *tagged `json:"storeError,omitempty"`
|
|
AgentError *tagged `json:"agentError,omitempty"`
|
|
} `json:"chatError"`
|
|
}
|
|
|
|
tagged struct {
|
|
Type string `json:"type"`
|
|
}
|
|
)
|
|
|
|
// Deleted reports whether the contact is gone, as it is once the person
|
|
// deletes their chat with the bot. The chat client still lists such a
|
|
// contact, with its chat, but nothing more reaches them.
|
|
func (c Contact) Deleted() bool {
|
|
return c.Status != "active"
|
|
}
|
|
|
|
// Message is a text message a contact sent to the bot.
|
|
type Message struct {
|
|
ContactID int64
|
|
ItemID int64
|
|
Text string
|
|
}
|
|
|
|
// Message returns the text message a contact sent in a direct chat, and
|
|
// false for anything else: group messages, files, the bot's own
|
|
// messages, and the event items the client records in a chat.
|
|
func (a AChatItem) Message() (Message, bool) {
|
|
item := a.ChatItem
|
|
|
|
if a.ChatInfo.Type != "direct" || a.ChatInfo.Contact == nil ||
|
|
item.ChatDir.Type != "directRcv" || item.Content.Type != "rcvMsgContent" ||
|
|
item.Content.MsgContent == nil || item.Content.MsgContent.Type != "text" {
|
|
return Message{}, false
|
|
}
|
|
|
|
return Message{
|
|
ContactID: a.ChatInfo.Contact.ContactID,
|
|
ItemID: item.Meta.ItemID,
|
|
Text: item.Content.MsgContent.Text,
|
|
}, true
|
|
}
|
|
|
|
// Command strings. Their syntax is documented per command in the
|
|
// simplex-chat repository, bots/api/COMMANDS.md.
|
|
|
|
const cmdShowActiveUser = "/user"
|
|
|
|
func cmdShowAddress(userID int64) string {
|
|
return "/_show_address " + strconv.FormatInt(userID, 10)
|
|
}
|
|
|
|
func cmdCreateAddress(userID int64) string {
|
|
return "/_address " + strconv.FormatInt(userID, 10)
|
|
}
|
|
|
|
func cmdSetAddressSettings(userID int64, s AddressSettings) (string, error) {
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
return "", fmt.Errorf("encoding address settings: %w", err)
|
|
}
|
|
|
|
return "/_address_settings " + strconv.FormatInt(userID, 10) + " " + string(b), nil
|
|
}
|
|
|
|
func cmdListContacts(userID int64) string {
|
|
return "/_contacts " + strconv.FormatInt(userID, 10)
|
|
}
|
|
|
|
// cmdGetChat asks for the last count items of the chat with a contact,
|
|
// or, unless beforeItemID is 0, the last count before that item. It is
|
|
// missing from COMMANDS.md; its syntax is the client's parser's, in
|
|
// src/Simplex/Chat/Library/Commands.hs.
|
|
func cmdGetChat(contactID, beforeItemID int64, count int) string {
|
|
cmd := "/_get chat @" + strconv.FormatInt(contactID, 10)
|
|
|
|
if beforeItemID != 0 {
|
|
cmd += " before=" + strconv.FormatInt(beforeItemID, 10)
|
|
}
|
|
|
|
return cmd + " count=" + strconv.Itoa(count)
|
|
}
|
|
|
|
func cmdSendText(contactID, quotedItemID int64, text string) (string, error) {
|
|
b, err := json.Marshal([]composedMessage{{
|
|
QuotedItemID: quotedItemID,
|
|
MsgContent: MsgContent{Type: "text", Text: text},
|
|
Mentions: map[string]int64{},
|
|
}})
|
|
if err != nil {
|
|
return "", fmt.Errorf("encoding message: %w", err)
|
|
}
|
|
|
|
return "/_send @" + strconv.FormatInt(contactID, 10) + " json " + string(b), nil
|
|
}
|