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
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
|
|
}
|