From a99d1b5007490539b2b7585ba880f92a0fbc4eb0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 13 Oct 2023 15:17:42 +0300 Subject: [PATCH] Add initial newsletter support --- download.go | 41 +++--- go.mod | 2 +- go.sum | 4 +- mdtest/go.mod | 2 +- mdtest/go.sum | 4 +- mdtest/main.go | 71 ++++++++++ message.go | 38 +++++- newsletter.go | 295 +++++++++++++++++++++++++++++++++++++++++ notification.go | 95 +++++++++++++ types/events/events.go | 20 +++ types/jid.go | 3 + types/message.go | 3 +- types/newsletter.go | 197 +++++++++++++++++++++++++++ types/user.go | 8 +- 14 files changed, 752 insertions(+), 31 deletions(-) create mode 100644 newsletter.go create mode 100644 types/newsletter.go diff --git a/download.go b/download.go index 784a85d0..95f0144d 100644 --- a/download.go +++ b/download.go @@ -221,6 +221,7 @@ func (cli *Client) DownloadMediaWithPath(directPath string, encFileHash, fileHas mmsType = mediaTypeToMMSType[mediaType] } for i, host := range mediaConn.Hosts { + // TODO omit hash for unencrypted media? mediaURL := fmt.Sprintf("https://%s%s&hash=%s&mms-type=%s&__wa-mms=", host.Hostname, directPath, base64.URLEncoding.EncodeToString(encFileHash), mmsType) data, err = cli.downloadAndDecrypt(mediaURL, mediaKey, mediaType, fileLength, encFileHash, fileHash) // TODO there are probably some errors that shouldn't retry @@ -237,8 +238,11 @@ func (cli *Client) DownloadMediaWithPath(directPath string, encFileHash, fileHas func (cli *Client) downloadAndDecrypt(url string, mediaKey []byte, appInfo MediaType, fileLength int, fileEncSha256, fileSha256 []byte) (data []byte, err error) { iv, cipherKey, macKey, _ := getMediaKeys(mediaKey, appInfo) var ciphertext, mac []byte - if ciphertext, mac, err = cli.downloadEncryptedMediaWithRetries(url, fileEncSha256); err != nil { + if ciphertext, mac, err = cli.downloadPossiblyEncryptedMediaWithRetries(url, fileEncSha256); err != nil { + } else if mediaKey == nil && fileEncSha256 == nil && mac == nil { + // Unencrypted media, just return the downloaded data + data = ciphertext } else if err = validateMedia(iv, ciphertext, macKey, mac); err != nil { } else if data, err = cbcutil.Decrypt(cipherKey, iv, ciphertext); err != nil { @@ -263,9 +267,13 @@ func shouldRetryMediaDownload(err error) bool { (errors.As(err, &httpErr) && retryafter.Should(httpErr.StatusCode, true)) } -func (cli *Client) downloadEncryptedMediaWithRetries(url string, checksum []byte) (file, mac []byte, err error) { +func (cli *Client) downloadPossiblyEncryptedMediaWithRetries(url string, checksum []byte) (file, mac []byte, err error) { for retryNum := 0; retryNum < 5; retryNum++ { - file, mac, err = cli.downloadEncryptedMedia(url, checksum) + if checksum == nil { + file, err = cli.downloadMedia(url) + } else { + file, mac, err = cli.downloadEncryptedMedia(url, checksum) + } if err == nil || !shouldRetryMediaDownload(err) { return } @@ -280,30 +288,27 @@ func (cli *Client) downloadEncryptedMediaWithRetries(url string, checksum []byte return } -func (cli *Client) downloadEncryptedMedia(url string, checksum []byte) (file, mac []byte, err error) { - var req *http.Request - req, err = http.NewRequest(http.MethodGet, url, nil) +func (cli *Client) downloadMedia(url string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { - err = fmt.Errorf("failed to prepare request: %w", err) - return + return nil, fmt.Errorf("failed to prepare request: %w", err) } req.Header.Set("Origin", socket.Origin) req.Header.Set("Referer", socket.Origin+"/") - var resp *http.Response - resp, err = cli.http.Do(req) + resp, err := cli.http.Do(req) if err != nil { - return + return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - err = DownloadHTTPError{Response: resp} - return + return nil, DownloadHTTPError{Response: resp} } - var data []byte - data, err = io.ReadAll(resp.Body) - if err != nil { - return - } else if len(data) <= 10 { + return io.ReadAll(resp.Body) +} + +func (cli *Client) downloadEncryptedMedia(url string, checksum []byte) (file, mac []byte, err error) { + data, err := cli.downloadMedia(url) + if len(data) <= 10 { err = ErrTooShortFile return } diff --git a/go.mod b/go.mod index fcd0b551..06a69686 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.20 require ( github.com/gorilla/websocket v1.5.0 go.mau.fi/libsignal v0.1.0 - go.mau.fi/util v0.1.0 + go.mau.fi/util v0.1.1-0.20231013112707-e938021823cc golang.org/x/crypto v0.13.0 google.golang.org/protobuf v1.31.0 ) diff --git a/go.sum b/go.sum index 29b09040..84d3c252 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= go.mau.fi/libsignal v0.1.0 h1:vAKI/nJ5tMhdzke4cTK1fb0idJzz1JuEIpmjprueC+c= go.mau.fi/libsignal v0.1.0/go.mod h1:R8ovrTezxtUNzCQE5PH30StOQWWeBskBsWE55vMfY9I= -go.mau.fi/util v0.1.0 h1:BwIFWIOEeO7lsiI2eWKFkWTfc5yQmoe+0FYyOFVyaoE= -go.mau.fi/util v0.1.0/go.mod h1:AxuJUMCxpzgJ5eV9JbPWKRH8aAJJidxetNdUj7qcb84= +go.mau.fi/util v0.1.1-0.20231013112707-e938021823cc h1:/ZY5g+McWqVSA6fK8ROBOyJFb5hCBBQKMcm2oRFzc9c= +go.mau.fi/util v0.1.1-0.20231013112707-e938021823cc/go.mod h1:AxuJUMCxpzgJ5eV9JbPWKRH8aAJJidxetNdUj7qcb84= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= diff --git a/mdtest/go.mod b/mdtest/go.mod index 37b32868..24383156 100644 --- a/mdtest/go.mod +++ b/mdtest/go.mod @@ -13,7 +13,7 @@ require ( filippo.io/edwards25519 v1.0.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect go.mau.fi/libsignal v0.1.0 // indirect - go.mau.fi/util v0.1.0 // indirect + go.mau.fi/util v0.1.1-0.20231013112707-e938021823cc // indirect golang.org/x/crypto v0.13.0 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/mdtest/go.sum b/mdtest/go.sum index be9675ca..befc78c3 100644 --- a/mdtest/go.sum +++ b/mdtest/go.sum @@ -17,8 +17,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= go.mau.fi/libsignal v0.1.0 h1:vAKI/nJ5tMhdzke4cTK1fb0idJzz1JuEIpmjprueC+c= go.mau.fi/libsignal v0.1.0/go.mod h1:R8ovrTezxtUNzCQE5PH30StOQWWeBskBsWE55vMfY9I= -go.mau.fi/util v0.1.0 h1:BwIFWIOEeO7lsiI2eWKFkWTfc5yQmoe+0FYyOFVyaoE= -go.mau.fi/util v0.1.0/go.mod h1:AxuJUMCxpzgJ5eV9JbPWKRH8aAJJidxetNdUj7qcb84= +go.mau.fi/util v0.1.1-0.20231013112707-e938021823cc h1:/ZY5g+McWqVSA6fK8ROBOyJFb5hCBBQKMcm2oRFzc9c= +go.mau.fi/util v0.1.1-0.20231013112707-e938021823cc/go.mod h1:AxuJUMCxpzgJ5eV9JbPWKRH8aAJJidxetNdUj7qcb84= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/mdtest/main.go b/mdtest/main.go index fb2bd392..dd14e1b3 100644 --- a/mdtest/main.go +++ b/mdtest/main.go @@ -356,6 +356,77 @@ func handleCmd(cmd string, args []string) { } else { log.Infof("Node sent") } + case "getnewsletter": + jid, ok := parseJID(args[0]) + if !ok { + return + } + meta, err := cli.GetNewsletterInfo(jid) + if err != nil { + log.Errorf("Failed to get info: %v", err) + } else { + log.Infof("Got info: %+v", meta) + } + case "livesubscribenewsletter": + if len(args) < 1 { + log.Errorf("Usage: livesubscribenewsletter ") + return + } + jid, ok := parseJID(args[0]) + if !ok { + return + } + dur, err := cli.SubscribeNewsletterLiveUpdates(context.TODO(), jid) + if err != nil { + log.Errorf("Failed to subscribe to live updates: %v", err) + } else { + log.Infof("Subscribed to live updates for %s for %s", jid, dur) + } + case "getnewslettermessages": + if len(args) < 1 { + log.Errorf("Usage: getnewslettermessages [count] [before id]") + return + } + jid, ok := parseJID(args[0]) + if !ok { + return + } + count := 100 + var err error + if len(args) > 1 { + count, err = strconv.Atoi(args[1]) + if err != nil { + log.Errorf("Invalid count: %v", err) + return + } + } + var before types.MessageServerID + if len(args) > 2 { + before, err = strconv.Atoi(args[2]) + if err != nil { + log.Errorf("Invalid message ID: %v", err) + return + } + } + messages, err := cli.GetNewsletterMessages(jid, &whatsmeow.GetNewsletterMessagesParams{Count: count, Before: before}) + if err != nil { + log.Errorf("Failed to get messages: %v", err) + } else { + for _, msg := range messages { + log.Infof("%d: %+v (viewed %d times)", msg.MessageServerID, msg.Message, msg.ViewsCount) + } + } + case "createnewsletter": + if len(args) < 1 { + log.Errorf("Usage: createnewsletter ") + return + } + err := cli.CreateNewsletter(strings.Join(args, " "), "") + if err != nil { + log.Errorf("Failed to create newsletter: %v", err) + } else { + log.Infof("Created newsletter?") + } case "getavatar": if len(args) < 1 { log.Errorf("Usage: getavatar [existing ID] [--preview] [--community]") diff --git a/message.go b/message.go index 4a37d102..c49956d4 100644 --- a/message.go +++ b/message.go @@ -45,7 +45,12 @@ func (cli *Client) handleEncryptedMessage(node *waBinary.Node) { if len(info.PushName) > 0 && info.PushName != "-" { go cli.updatePushName(info.Sender, info, info.PushName) } - cli.decryptMessages(info, node) + go cli.sendAck(node) + if info.Sender.Server == types.NewsletterServer { + cli.handlePlaintextMessage(info, node) + } else { + cli.decryptMessages(info, node) + } } } @@ -71,6 +76,10 @@ func (cli *Client) parseMessageSource(node *waBinary.Node, requireParticipant bo if from.Server == types.BroadcastServer { source.BroadcastListOwner = ag.OptionalJIDOrEmpty("recipient") } + } else if from.Server == types.NewsletterServer { + source.Chat = from + source.Sender = from + // TODO IsFromMe? } else if from.User == clientID.User { source.IsFromMe = true source.Sender = from @@ -97,6 +106,7 @@ func (cli *Client) parseMessageInfo(node *waBinary.Node) (*types.MessageInfo, er } ag := node.AttrGetter() info.ID = types.MessageID(ag.String("id")) + info.ServerID = types.MessageServerID(ag.OptionalInt("server_id")) info.Timestamp = ag.UnixTime("t") info.PushName = ag.OptionalString("notify") info.Category = ag.OptionalString("category") @@ -121,14 +131,38 @@ func (cli *Client) parseMessageInfo(node *waBinary.Node) (*types.MessageInfo, er return &info, nil } +func (cli *Client) handlePlaintextMessage(info *types.MessageInfo, node *waBinary.Node) { + // TODO edits have an additional node + plaintext, ok := node.GetOptionalChildByTag("plaintext") + if !ok { + // 3: + return + } + plaintextBody, ok := plaintext.Content.([]byte) + if !ok { + cli.Log.Warnf("Plaintext message from %s doesn't have byte content", info.SourceString()) + return + } + var msg waProto.Message + err := proto.Unmarshal(plaintextBody, &msg) + if err != nil { + cli.Log.Warnf("Error unmarshaling plaintext message from %s: %v", info.SourceString(), err) + return + } + cli.handleDecryptedMessage(info, &msg, 0) + // TODO do these need receipts? + //go cli.sendMessageReceipt(info) + return +} + func (cli *Client) decryptMessages(info *types.MessageInfo, node *waBinary.Node) { - go cli.sendAck(node) if len(node.GetChildrenByTag("unavailable")) > 0 && len(node.GetChildrenByTag("enc")) == 0 { cli.Log.Warnf("Unavailable message %s from %s", info.ID, info.SourceString()) go cli.sendRetryReceipt(node, info, true) cli.dispatchEvent(&events.UndecryptableMessage{Info: *info, IsUnavailable: true}) return } + children := node.GetChildren() cli.Log.Debugf("Decrypting %d messages from %s", len(children), info.SourceString()) handled := false diff --git a/newsletter.go b/newsletter.go new file mode 100644 index 00000000..9f905345 --- /dev/null +++ b/newsletter.go @@ -0,0 +1,295 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package whatsmeow + +import ( + "context" + "encoding/json" + "fmt" + "time" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +// SubscribeNewsletterLiveUpdates subscribes to receive live updates from a newsletter temporarily (for the duration returned). +func (cli *Client) SubscribeNewsletterLiveUpdates(ctx context.Context, jid types.JID) (time.Duration, error) { + resp, err := cli.sendIQ(infoQuery{ + Context: ctx, + Namespace: "newsletter", + Type: iqSet, + To: jid, + Content: []waBinary.Node{{ + Tag: "live_updates", + }}, + }) + if err != nil { + return 0, err + } + child := resp.GetChildByTag("live_updates") + dur := child.AttrGetter().Int("duration") + return time.Duration(dur) * time.Second, nil +} + +func (cli *Client) NewsletterMarkViewed(jid types.JID, serverIDs []types.MessageServerID) error { + items := make([]waBinary.Node, len(serverIDs)) + for i, id := range serverIDs { + items[i] = waBinary.Node{ + Tag: "item", + Attrs: waBinary.Attrs{ + "server_id": id, + }, + } + } + reqID := cli.generateRequestID() + resp := cli.waitResponse(reqID) + err := cli.sendNode(waBinary.Node{ + Tag: "receipt", + Attrs: waBinary.Attrs{ + "to": jid, + "type": "view", + "id": reqID, + }, + Content: []waBinary.Node{{ + Tag: "list", + Content: items, + }}, + }) + if err != nil { + cli.cancelResponse(reqID, resp) + return err + } + // TODO handle response? + <-resp + return nil +} + +// NewsletterSendReaction sends a reaction to a newsletter message. +// To remove a reaction sent earlier, set reaction to an empty string. +// +// The last parameter is the message ID of the reaction itself. It can be left empty to let whatsmeow generate a random one. +func (cli *Client) NewsletterSendReaction(jid types.JID, serverID types.MessageServerID, reaction string, messageID types.MessageID) error { + if messageID == "" { + messageID = cli.GenerateMessageID() + } + reactionAttrs := waBinary.Attrs{} + messageAttrs := waBinary.Attrs{ + "to": jid, + "id": messageID, + "server_id": serverID, + "type": "reaction", + } + if reaction != "" { + reactionAttrs["code"] = reaction + } else { + messageAttrs["edit"] = EditAttributeSenderRevoke + } + return cli.sendNode(waBinary.Node{ + Tag: "message", + Attrs: messageAttrs, + Content: []waBinary.Node{{ + Tag: "reaction", + Attrs: reactionAttrs, + }}, + }) +} + +const ( + queryFetchNewsletter = "6563316087068696" + queryFetchNewsletterDehydrated = "7272540469429201" + mutationMuteNewsletter = "6274038279359549" + mutationUnmuteNewsletter = "6068417879924485" + mutationUpdateNewsletter = "7150902998257522" + mutationCreateNewsletter = "6234210096708695" +) + +func (cli *Client) sendMexIQ(ctx context.Context, queryID string, variables map[string]any) (json.RawMessage, error) { + payload, err := json.Marshal(map[string]any{ + "variables": variables, + }) + if err != nil { + return nil, err + } + resp, err := cli.sendIQ(infoQuery{ + Namespace: "w:mex", + Type: iqGet, + To: types.ServerJID, + Content: []waBinary.Node{{ + Tag: "query", + Attrs: waBinary.Attrs{ + "query_id": queryID, + }, + Content: payload, + }}, + Context: ctx, + }) + if err != nil { + return nil, err + } + result, ok := resp.GetOptionalChildByTag("result") + if !ok { + return nil, &ElementMissingError{Tag: "result", In: "mex response"} + } + resultContent, ok := result.Content.([]byte) + if !ok { + return nil, fmt.Errorf("unexpected content type %T in mex response", result.Content) + } + var gqlResp types.GraphQLResponse + err = json.Unmarshal(resultContent, &gqlResp) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal graphql response: %w", err) + } else if len(gqlResp.Errors) > 0 { + return gqlResp.Data, fmt.Errorf("graphql error: %w", gqlResp.Errors) + } + return gqlResp.Data, nil +} + +type respGetNewsletterInfo struct { + Newsletter *types.NewsletterMetadata `json:"xwa2_newsletter"` +} + +func (cli *Client) getNewsletterInfo(input map[string]any) (*types.NewsletterMetadata, error) { + data, err := cli.sendMexIQ(context.TODO(), queryFetchNewsletter, map[string]any{ + "fetch_creation_time": true, + "fetch_full_image": true, + "fetch_viewer_metadata": true, + "input": input, + }) + if err != nil { + return nil, err + } + var respData respGetNewsletterInfo + err = json.Unmarshal(data, &respData) + if err != nil { + return nil, err + } + return respData.Newsletter, nil +} + +func (cli *Client) GetNewsletterInfo(jid types.JID) (*types.NewsletterMetadata, error) { + return cli.getNewsletterInfo(map[string]any{ + "key": jid.String(), + "type": types.NewsletterKeyTypeJID, + }) +} + +func (cli *Client) GetNewsletterInfoWithInvite(key string) (*types.NewsletterMetadata, error) { + return cli.getNewsletterInfo(map[string]any{ + "key": key, + "type": types.NewsletterKeyTypeInvite, + }) +} + +func (cli *Client) CreateNewsletter(name, description string) error { + _, err := cli.sendMexIQ(context.TODO(), mutationCreateNewsletter, map[string]any{ + "newsletter_input": map[string]any{ + "name": name, + "description": description, + }, + }) + return err +} + +func (cli *Client) ToggleNewsletterMute(jid types.JID, mute bool) error { + query := mutationUnmuteNewsletter + if mute { + query = mutationMuteNewsletter + } + _, err := cli.sendMexIQ(context.TODO(), query, map[string]any{ + "newsletter_id": jid.String(), + }) + return err +} + +type GetNewsletterMessagesParams struct { + Count int + Before types.MessageServerID +} + +func (cli *Client) GetNewsletterMessages(jid types.JID, params *GetNewsletterMessagesParams) ([]*types.NewsletterMessage, error) { + attrs := waBinary.Attrs{ + "type": "jid", + "jid": jid, + } + if params != nil { + if params.Count != 0 { + attrs["count"] = params.Count + } + if params.Before != 0 { + attrs["before"] = params.Before + } + } + resp, err := cli.sendIQ(infoQuery{ + Namespace: "newsletter", + Type: iqGet, + To: types.ServerJID, + Content: []waBinary.Node{{ + Tag: "messages", + Attrs: attrs, + }}, + Context: context.TODO(), + }) + if err != nil { + return nil, err + } + messages, ok := resp.GetOptionalChildByTag("messages") + if !ok { + return nil, &ElementMissingError{Tag: "messages", In: "newsletter messages response"} + } + return cli.parseNewsletterMessages(&messages), nil +} + +type GetNewsletterUpdatesParams struct { + Count int + Since time.Time + After types.MessageServerID +} + +func (cli *Client) GetNewsletterMessageUpdates(jid types.JID, params *GetNewsletterUpdatesParams) ([]*types.NewsletterMessage, error) { + attrs := waBinary.Attrs{} + if params != nil { + if params.Count != 0 { + attrs["count"] = params.Count + } + if !params.Since.IsZero() { + attrs["since"] = params.Since.Unix() + } + if params.After != 0 { + attrs["after"] = params.After + } + } + resp, err := cli.sendIQ(infoQuery{ + Namespace: "newsletter", + Type: iqGet, + To: jid, + Content: []waBinary.Node{{ + Tag: "message_updates", + Attrs: attrs, + }}, + Context: context.TODO(), + }) + if err != nil { + return nil, err + } + messages, ok := resp.GetOptionalChildByTag("message_updates", "messages") + if !ok { + return nil, &ElementMissingError{Tag: "messages", In: "newsletter messages response"} + } + return cli.parseNewsletterMessages(&messages), nil +} + +// SEND +// RECV ??? + +// SEND +// RECV ??? + +// SEND +// RECV <!-- 2565 bytes --></plaintext></message></messages></iq> + +// SEND <iq to="s.whatsapp.net" xmlns="newsletter" id="18.32935-179" type="get"><my_reactions limit="5000" jid="120363169319669622@newsletter" /></iq> +// RECV <iq from="s.whatsapp.net" type="result" id="18.32935-179"><my_reactions><messages jid="120363169319669622@newsletter" /></my_reactions></iq> diff --git a/notification.go b/notification.go index 97b8ff7d..decd6867 100644 --- a/notification.go +++ b/notification.go @@ -7,10 +7,14 @@ package whatsmeow import ( + "encoding/json" "errors" + "google.golang.org/protobuf/proto" + "go.mau.fi/whatsmeow/appstate" waBinary "go.mau.fi/whatsmeow/binary" + waProto "go.mau.fi/whatsmeow/binary/proto" "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" @@ -253,6 +257,93 @@ func (cli *Client) handlePrivacyTokenNotification(node *waBinary.Node) { } } +func (cli *Client) parseNewsletterMessages(node *waBinary.Node) []*types.NewsletterMessage { + children := node.GetChildren() + output := make([]*types.NewsletterMessage, 0, len(children)) + for _, child := range children { + if child.Tag != "message" { + continue + } + msg := types.NewsletterMessage{ + MessageServerID: child.AttrGetter().Int("server_id"), + ViewsCount: 0, + ReactionCounts: nil, + } + for _, subchild := range child.GetChildren() { + switch subchild.Tag { + case "plaintext": + byteContent, ok := subchild.Content.([]byte) + if ok { + msg.Message = new(waProto.Message) + err := proto.Unmarshal(byteContent, msg.Message) + if err != nil { + cli.Log.Warnf("Failed to unmarshal newsletter message: %v", err) + msg.Message = nil + } + } + case "views_count": + msg.ViewsCount = subchild.AttrGetter().Int("count") + case "reactions": + msg.ReactionCounts = make(map[string]int) + for _, reaction := range subchild.GetChildren() { + rag := reaction.AttrGetter() + msg.ReactionCounts[rag.String("code")] = rag.Int("count") + } + } + } + output = append(output, &msg) + } + return output +} + +func (cli *Client) handleNewsletterNotification(node *waBinary.Node) { + ag := node.AttrGetter() + liveUpdates := node.GetChildByTag("live_updates") + cli.dispatchEvent(&events.NewsletterLiveUpdate{ + JID: ag.JID("from"), + Time: ag.UnixTime("t"), + Messages: cli.parseNewsletterMessages(&liveUpdates), + }) +} + +type newsLetterEventWrapper struct { + Data newsletterEvent `json:"data"` +} + +type newsletterEvent struct { + Join *events.NewsletterJoin `json:"xwa2_notify_newsletter_on_join"` + Leave *events.NewsletterLeave `json:"xwa2_notify_newsletter_on_leave"` + MuteChange *events.NewsletterMuteChange `json:"xwa2_notify_newsletter_on_mute_change"` + // _on_admin_metadata_update -> id, thread_metadata, messages + // _on_metadata_update + // _on_state_change -> id, is_requestor, state +} + +func (cli *Client) handleMexNotification(node *waBinary.Node) { + for _, child := range node.GetChildren() { + if child.Tag != "update" { + continue + } + childData, ok := child.Content.([]byte) + if !ok { + continue + } + var wrapper newsLetterEventWrapper + err := json.Unmarshal(childData, &wrapper) + if err != nil { + cli.Log.Errorf("Failed to unmarshal JSON in mex event: %v", err) + continue + } + if wrapper.Data.Join != nil { + cli.dispatchEvent(wrapper.Data.Join) + } else if wrapper.Data.Leave != nil { + cli.dispatchEvent(wrapper.Data.Leave) + } else if wrapper.Data.MuteChange != nil { + cli.dispatchEvent(wrapper.Data.MuteChange) + } + } +} + func (cli *Client) handleNotification(node *waBinary.Node) { ag := node.AttrGetter() notifType := ag.String("type") @@ -284,6 +375,10 @@ func (cli *Client) handleNotification(node *waBinary.Node) { go cli.handlePrivacyTokenNotification(node) case "link_code_companion_reg": go cli.tryHandleCodePairNotification(node) + case "newsletter": + go cli.handleNewsletterNotification(node) + case "mex": + go cli.handleMexNotification(node) // Other types: business, disappearing_mode, server, status, pay, psa default: cli.Log.Debugf("Unhandled notification with type %s", notifType) diff --git a/types/events/events.go b/types/events/events.go index ffd52753..b93ba4e3 100644 --- a/types/events/events.go +++ b/types/events/events.go @@ -487,3 +487,23 @@ type BlocklistChange struct { JID types.JID Action BlocklistChangeAction } + +type NewsletterJoin struct { + types.NewsletterMetadata +} + +type NewsletterLeave struct { + ID types.JID `json:"id"` + Role types.NewsletterRole `json:"role"` +} + +type NewsletterMuteChange struct { + ID types.JID `json:"id"` + Mute types.NewsletterMuteState `json:"mute"` +} + +type NewsletterLiveUpdate struct { + JID types.JID + Time time.Time + Messages []*types.NewsletterMessage +} diff --git a/types/jid.go b/types/jid.go index 28547509..31c9bed6 100644 --- a/types/jid.go +++ b/types/jid.go @@ -44,6 +44,9 @@ var ( // MessageID is the internal ID of a WhatsApp message. type MessageID = string +// MessageServerID is the server ID of a WhatsApp newsletter message. +type MessageServerID = int + // JID represents a WhatsApp user ID. // // There are two types of JIDs: regular JID pairs (user and server) and AD-JIDs (user, agent and device). diff --git a/types/message.go b/types/message.go index 0681268c..3c1bb70c 100644 --- a/types/message.go +++ b/types/message.go @@ -39,7 +39,8 @@ type DeviceSentMeta struct { // MessageInfo contains metadata about an incoming message. type MessageInfo struct { MessageSource - ID string + ID MessageID + ServerID MessageServerID Type string PushName string Timestamp time.Time diff --git a/types/newsletter.go b/types/newsletter.go new file mode 100644 index 00000000..52adb5ed --- /dev/null +++ b/types/newsletter.go @@ -0,0 +1,197 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package types + +import ( + "bytes" + "encoding/json" + "fmt" + + "go.mau.fi/util/jsontime" + + waProto "go.mau.fi/whatsmeow/binary/proto" +) + +type NewsletterVerificationState string + +func (nvs *NewsletterVerificationState) UnmarshalText(text []byte) error { + *nvs = NewsletterVerificationState(bytes.ToLower(text)) + return nil +} + +const ( + NewsletterVerificationStateVerified NewsletterVerificationState = "verified" + NewsletterVerificationStateUnverified NewsletterVerificationState = "unverified" +) + +type NewsletterPrivacy string + +func (np *NewsletterPrivacy) UnmarshalText(text []byte) error { + *np = NewsletterPrivacy(bytes.ToLower(text)) + return nil +} + +const ( + NewsletterPrivacyPrivate NewsletterPrivacy = "private" + NewsletterPrivacyPublic NewsletterPrivacy = "public" +) + +type NewsletterReactionsMode string + +const ( + NewsletterReactionsModeAll NewsletterReactionsMode = "all" + NewsletterReactionsModeBasic NewsletterReactionsMode = "basic" + NewsletterReactionsModeNone NewsletterReactionsMode = "none" + NewsletterReactionsModeBlocklist NewsletterReactionsMode = "blocklist" +) + +type NewsletterState string + +func (ns *NewsletterState) UnmarshalText(text []byte) error { + *ns = NewsletterState(bytes.ToLower(text)) + return nil +} + +const ( + NewsletterStateActive NewsletterState = "active" + NewsletterStateSuspended NewsletterState = "suspended" + NewsletterStateGeoSuspended NewsletterState = "geosuspended" +) + +type NewsletterMuted struct { + Muted bool +} + +type WrappedNewsletterState struct { + Type NewsletterState `json:"type"` +} + +type NewsletterMuteState string + +func (nms *NewsletterMuteState) UnmarshalText(text []byte) error { + *nms = NewsletterMuteState(bytes.ToLower(text)) + return nil +} + +const ( + NewsletterMuteOn NewsletterMuteState = "on" + NewsletterMuteOff NewsletterMuteState = "off" +) + +type NewsletterRole string + +func (nr *NewsletterRole) UnmarshalText(text []byte) error { + *nr = NewsletterRole(bytes.ToLower(text)) + return nil +} + +const ( + NewsletterRoleSubscriber NewsletterRole = "subscriber" + NewsletterRoleGuest NewsletterRole = "guest" + NewsletterRoleAdmin NewsletterRole = "admin" + NewsletterRoleOwner NewsletterRole = "owner" +) + +type NewsletterMetadata struct { + ID JID `json:"id"` + State WrappedNewsletterState `json:"state"` + ThreadMeta NewsletterThreadMetadata `json:"thread_metadata"` + ViewerMeta NewsletterViewerMetadata `json:"viewer_metadata"` +} + +type NewsletterViewerMetadata struct { + Mute NewsletterMuteState `json:"mute"` + Role NewsletterRole `json:"role"` +} + +type NewsletterKeyType string + +const ( + NewsletterKeyTypeJID NewsletterKeyType = "JID" + NewsletterKeyTypeInvite NewsletterKeyType = "INVITE" +) + +type NewsletterReactionSettings struct { + Value NewsletterReactionsMode `json:"value"` +} + +type NewsletterSettings struct { + ReactionCodes NewsletterReactionSettings `json:"reaction_codes"` +} + +type NewsletterThreadMetadata struct { + CreationTime jsontime.UnixString `json:"creation_time"` + InviteCode string `json:"invite"` + Name NewsletterText `json:"name"` + Description NewsletterText `json:"description"` + SubscriberCount int `json:"subscribers_count,string"` + VerificationState NewsletterVerificationState `json:"verification"` + Picture *ProfilePictureInfo `json:"image"` + Preview ProfilePictureInfo `json:"preview"` + Settings NewsletterSettings `json:"settings"` + + //NewsletterMuted `json:"-"` + //PrivacyType NewsletterPrivacy `json:"-"` + //ReactionsMode NewsletterReactionsMode `json:"-"` + //State NewsletterState `json:"-"` +} + +type NewsletterText struct { + Text string `json:"text"` + ID string `json:"id"` + UpdateTime jsontime.UnixMicroString `json:"update_time"` +} + +type NewsletterMessage struct { + MessageServerID MessageServerID + ViewsCount int + ReactionCounts map[string]int + + // This is only present when fetching messages, not in live updates + Message *waProto.Message +} + +type GraphQLErrorExtensions struct { + ErrorCode int `json:"error_code"` + IsRetryable bool `json:"is_retryable"` + Severity string `json:"severity"` +} + +type GraphQLError struct { + Extensions GraphQLErrorExtensions `json:"extensions"` + Message string `json:"message"` + Path []string `json:"path"` +} + +func (gqle GraphQLError) Error() string { + return fmt.Sprintf("%d %s (%s)", gqle.Extensions.ErrorCode, gqle.Message, gqle.Extensions.Severity) +} + +type GraphQLErrors []GraphQLError + +func (gqles GraphQLErrors) Unwrap() []error { + errs := make([]error, len(gqles)) + for i, gqle := range gqles { + errs[i] = gqle + } + return errs +} + +func (gqles GraphQLErrors) Error() string { + if len(gqles) == 0 { + return "" + } else if len(gqles) == 1 { + return gqles[0].Error() + } else { + return fmt.Sprintf("%v (and %d other errors)", gqles[0], len(gqles)-1) + } +} + +type GraphQLResponse struct { + Data json.RawMessage `json:"data"` + Errors GraphQLErrors `json:"errors"` +} diff --git a/types/user.go b/types/user.go index fe51258b..3b6f37d5 100644 --- a/types/user.go +++ b/types/user.go @@ -28,11 +28,11 @@ type UserInfo struct { // ProfilePictureInfo contains the ID and URL for a WhatsApp user's profile picture or group's photo. type ProfilePictureInfo struct { - URL string // The full URL for the image, can be downloaded with a simple HTTP request. - ID string // The ID of the image. This is the same as UserInfo.PictureID. - Type string // The type of image. Known types include "image" (full res) and "preview" (thumbnail). + URL string `json:"url"` // The full URL for the image, can be downloaded with a simple HTTP request. + ID string `json:"id"` // The ID of the image. This is the same as UserInfo.PictureID. + Type string `json:"type"` // The type of image. Known types include "image" (full res) and "preview" (thumbnail). - DirectPath string // The path to the image, probably not very useful + DirectPath string `json:"direct_path"` // The path to the image, probably not very useful } // ContactInfo contains the cached names of a WhatsApp user.