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 }