1
0
forked from lug/matterbridge

Update vendor (whatsapp)

This commit is contained in:
Wim
2022-03-12 23:02:04 +01:00
parent 1b9877fda4
commit aefa70891c
206 changed files with 367071 additions and 164229 deletions

View File

@@ -1,21 +1,13 @@
# whatsmeow
[![godocs.io](https://godocs.io/go.mau.fi/whatsmeow?status.svg)](https://godocs.io/go.mau.fi/whatsmeow)
[![Go Reference](https://pkg.go.dev/badge/go.mau.fi/whatsmeow.svg)](https://pkg.go.dev/go.mau.fi/whatsmeow)
whatsmeow is a Go library for the WhatsApp web multidevice API.
This was initially forked from [go-whatsapp] (MIT license), but large parts of
the code have been rewritten for multidevice support. Parts of the code are
ported from [WhatsappWeb4j] and [Baileys] (also MIT license).
[go-whatsapp]: https://github.com/Rhymen/go-whatsapp
[WhatsappWeb4j]: https://github.com/Auties00/WhatsappWeb4j
[Baileys]: https://github.com/adiwajshing/Baileys
## Discussion
Matrix room: [#whatsmeow:maunium.net](https://matrix.to/#/#whatsmeow:maunium.net)
## Usage
The [godoc](https://godocs.io/go.mau.fi/whatsmeow) includes docs for all methods and event types.
The [godoc](https://pkg.go.dev/go.mau.fi/whatsmeow) includes docs for all methods and event types.
There's also a [simple example](https://godocs.io/go.mau.fi/whatsmeow#example-package) at the top.
Also see [mdtest](./mdtest) for a CLI tool you can easily try out whatsmeow with.

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2021 Tulir Asokan
// Copyright (c) 2022 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
@@ -13,6 +13,7 @@ import (
"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"
)
@@ -54,7 +55,18 @@ func (cli *Client) FetchAppState(name appstate.WAPatchName, fullSync, onlyIfNotS
if err != nil {
return fmt.Errorf("failed to decode app state %s patches: %w", name, err)
}
wasFullSync := state.Version == 0 && patches.Snapshot != nil
state = newState
if name == appstate.WAPatchCriticalUnblockLow && wasFullSync && !cli.EmitAppStateEventsOnFullSync {
var contacts []store.ContactEntry
mutations, contacts = cli.filterContacts(mutations)
cli.Log.Debugf("Mass inserting app state snapshot with %d contacts into the store", len(contacts))
err = cli.Store.Contacts.PutAllContactNames(contacts)
if err != nil {
// This is a fairly serious failure, so just abort the whole thing
return fmt.Errorf("failed to update contact store with data from snapshot: %v", err)
}
}
for _, mutation := range mutations {
cli.dispatchAppState(mutation, !fullSync || cli.EmitAppStateEventsOnFullSync)
}
@@ -68,6 +80,25 @@ func (cli *Client) FetchAppState(name appstate.WAPatchName, fullSync, onlyIfNotS
return nil
}
func (cli *Client) filterContacts(mutations []appstate.Mutation) ([]appstate.Mutation, []store.ContactEntry) {
filteredMutations := mutations[:0]
contacts := make([]store.ContactEntry, 0, len(mutations))
for _, mutation := range mutations {
if mutation.Index[0] == "contact" && len(mutation.Index) > 1 {
jid, _ := types.ParseJID(mutation.Index[1])
act := mutation.Action.GetContactAction()
contacts = append(contacts, store.ContactEntry{
JID: jid,
FirstName: act.GetFirstName(),
FullName: act.GetFullName(),
})
} else {
filteredMutations = append(filteredMutations, mutation)
}
}
return filteredMutations, contacts
}
func (cli *Client) dispatchAppState(mutation appstate.Mutation, dispatchEvts bool) {
if mutation.Operation != waProto.SyncdMutation_SET {
return
@@ -144,6 +175,12 @@ func (cli *Client) dispatchAppState(mutation appstate.Mutation, dispatchEvts boo
evt.SenderJID, _ = types.ParseJID(mutation.Index[4])
}
eventToDispatch = &evt
case "markChatAsRead":
eventToDispatch = &events.MarkChatAsRead{
JID: jid,
Timestamp: ts,
Action: mutation.Action.GetMarkChatAsReadAction(),
}
case "setting_pushName":
eventToDispatch = &events.PushNameSetting{Timestamp: ts, Action: mutation.Action.GetPushNameSetting()}
cli.Store.PushName = mutation.Action.GetPushNameSetting().GetName()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,10 @@ var (
}
)
// DictVersion is the version number of the token lists above.
// It's sent when connecting to the websocket so the server knows which tokens are supported.
const DictVersion = 2
type doubleByteTokenIndex struct {
dictionary byte
index byte

View File

@@ -13,6 +13,8 @@ import (
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"runtime/debug"
"sync"
"sync/atomic"
@@ -56,6 +58,8 @@ type Client struct {
LastSuccessfulConnect time.Time
AutoReconnectErrors int
sendActiveReceipts uint32
// EmitAppStateEventsOnFullSync can be set to true if you want to get app state events emitted
// even when re-syncing the whole state.
EmitAppStateEventsOnFullSync bool
@@ -100,6 +104,9 @@ type Client struct {
uniqueID string
idCounter uint32
proxy socket.Proxy
http *http.Client
}
// Size of buffer for the channel that all incoming XML nodes go through.
@@ -128,6 +135,10 @@ func NewClient(deviceStore *store.Device, log waLog.Logger) *Client {
randomBytes := make([]byte, 2)
_, _ = rand.Read(randomBytes)
cli := &Client{
http: &http.Client{
Transport: (http.DefaultTransport.(*http.Transport)).Clone(),
},
proxy: http.ProxyFromEnvironment,
Store: deviceStore,
Log: log,
recvLog: log.Sub("Recv"),
@@ -159,10 +170,46 @@ func NewClient(deviceStore *store.Device, log waLog.Logger) *Client {
"stream:error": cli.handleStreamError,
"iq": cli.handleIQ,
"ib": cli.handleIB,
// Apparently there's also an <error> node which can have a code=479 and means "Invalid stanza sent (smax-invalid)"
}
return cli
}
// SetProxyAddress is a helper method that parses a URL string and calls SetProxy.
//
// Returns an error if url.Parse fails to parse the given address.
func (cli *Client) SetProxyAddress(addr string) error {
parsed, err := url.Parse(addr)
if err != nil {
return err
}
cli.SetProxy(http.ProxyURL(parsed))
return nil
}
// SetProxy sets the proxy to use for WhatsApp web websocket connections and media uploads/downloads.
//
// Must be called before Connect() to take effect in the websocket connection.
// If you want to change the proxy after connecting, you must call Disconnect() and then Connect() again manually.
//
// By default, the client will find the proxy from the https_proxy environment variable like Go's net/http does.
//
// To disable reading proxy info from environment variables, explicitly set the proxy to nil:
// cli.SetProxy(nil)
//
// To use a different proxy for the websocket and media, pass a function that checks the request path or headers:
// cli.SetProxy(func(r *http.Request) (*url.URL, error) {
// if r.URL.Host == "web.whatsapp.com" && r.URL.Path == "/ws/chat" {
// return websocketProxyURL, nil
// } else {
// return mediaProxyURL, nil
// }
// })
func (cli *Client) SetProxy(proxy socket.Proxy) {
cli.proxy = proxy
cli.http.Transport.(*http.Transport).Proxy = proxy
}
// Connect connects the client to the WhatsApp web websocket. After connection, it will either
// authenticate if there's data in the device store, or emit a QREvent to set up a new link.
func (cli *Client) Connect() error {
@@ -177,7 +224,7 @@ func (cli *Client) Connect() error {
}
cli.resetExpectedDisconnect()
fs := socket.NewFrameSocket(cli.Log.Sub("Socket"), socket.WAConnHeader)
fs := socket.NewFrameSocket(cli.Log.Sub("Socket"), socket.WAConnHeader, cli.proxy)
if err := fs.Connect(); err != nil {
fs.Close(0)
return err
@@ -313,29 +360,29 @@ func (cli *Client) Logout() error {
//
// All registered event handlers will receive all events. You should use a type switch statement to
// filter the events you want:
// func myEventHandler(evt interface{}) {
// switch v := evt.(type) {
// case *events.Message:
// fmt.Println("Received a message!")
// case *events.Receipt:
// fmt.Println("Received a receipt!")
// }
// }
// func myEventHandler(evt interface{}) {
// switch v := evt.(type) {
// case *events.Message:
// fmt.Println("Received a message!")
// case *events.Receipt:
// fmt.Println("Received a receipt!")
// }
// }
//
// If you want to access the Client instance inside the event handler, the recommended way is to
// wrap the whole handler in another struct:
// type MyClient struct {
// WAClient *whatsmeow.Client
// eventHandlerID uint32
// }
// type MyClient struct {
// WAClient *whatsmeow.Client
// eventHandlerID uint32
// }
//
// func (mycli *MyClient) register() {
// mycli.eventHandlerID = mycli.WAClient.AddEventHandler(mycli.myEventHandler)
// }
// func (mycli *MyClient) register() {
// mycli.eventHandlerID = mycli.WAClient.AddEventHandler(mycli.myEventHandler)
// }
//
// func (mycli *MyClient) myEventHandler(evt interface{}) {
// // Handle event and access mycli.WAClient
// }
// func (mycli *MyClient) myEventHandler(evt interface{}) {
// // Handle event and access mycli.WAClient
// }
func (cli *Client) AddEventHandler(handler EventHandler) uint32 {
nextID := atomic.AddUint32(&nextHandlerID, 1)
cli.eventHandlersLock.Lock()
@@ -350,11 +397,11 @@ func (cli *Client) AddEventHandler(handler EventHandler) uint32 {
// N.B. Do not run this directly from an event handler. That would cause a deadlock because the
// event dispatcher holds a read lock on the event handler list, and this method wants a write lock
// on the same list. Instead run it in a goroutine:
// func (mycli *MyClient) myEventHandler(evt interface{}) {
// if noLongerWantEvents {
// go mycli.WAClient.RemoveEventHandler(mycli.eventHandlerID)
// }
// }
// func (mycli *MyClient) myEventHandler(evt interface{}) {
// if noLongerWantEvents {
// go mycli.WAClient.RemoveEventHandler(mycli.eventHandlerID)
// }
// }
func (cli *Client) RemoveEventHandler(id uint32) bool {
cli.eventHandlersLock.Lock()
defer cli.eventHandlersLock.Unlock()

View File

@@ -33,7 +33,7 @@ func (cli *Client) handleStreamError(node *waBinary.Node) {
case code == "401" && conflictType == "device_removed":
cli.expectDisconnect()
cli.Log.Infof("Got device removed stream error, sending LoggedOut event and deleting session")
go cli.dispatchEvent(&events.LoggedOut{OnConnect: false})
go cli.dispatchEvent(&events.LoggedOut{OnConnect: false, Reason: events.ConnectFailureLoggedOut})
err := cli.Store.Delete()
if err != nil {
cli.Log.Warnf("Failed to delete store after device_removed error: %v", err)
@@ -77,17 +77,30 @@ func (cli *Client) handleIB(node *waBinary.Node) {
func (cli *Client) handleConnectFailure(node *waBinary.Node) {
ag := node.AttrGetter()
reason := ag.String("reason")
if reason == "401" {
cli.expectDisconnect()
cli.Log.Infof("Got 401 connect failure, sending LoggedOut event and deleting session")
go cli.dispatchEvent(&events.LoggedOut{OnConnect: true})
reason := events.ConnectFailureReason(ag.Int("reason"))
cli.expectDisconnect()
if reason.IsLoggedOut() {
cli.Log.Infof("Got %s connect failure, sending LoggedOut event and deleting session", reason)
go cli.dispatchEvent(&events.LoggedOut{OnConnect: true, Reason: reason})
err := cli.Store.Delete()
if err != nil {
cli.Log.Warnf("Failed to delete store after 401 failure: %v", err)
cli.Log.Warnf("Failed to delete store after %d failure: %v", int(reason), err)
}
} else if reason == events.ConnectFailureTempBanned {
cli.Log.Warnf("Temporary ban connect failure: %s", node.XMLString())
expiryTimeUnix := ag.Int64("expire")
var expiryTime time.Time
if expiryTimeUnix > 0 {
expiryTime = time.Unix(expiryTimeUnix, 0)
}
go cli.dispatchEvent(&events.TemporaryBan{
Code: events.TempBanReason(ag.Int("code")),
Expire: expiryTime,
})
} else if reason == events.ConnectFailureClientOutdated {
cli.Log.Errorf("Client outdated (405) connect failure")
go cli.dispatchEvent(&events.ClientOutdated{})
} else {
cli.expectDisconnect()
cli.Log.Warnf("Unknown connect failure: %s", node.XMLString())
go cli.dispatchEvent(&events.ConnectFailure{Reason: reason, Raw: node})
}

View File

@@ -18,6 +18,7 @@ import (
"google.golang.org/protobuf/reflect/protoreflect"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/socket"
"go.mau.fi/whatsmeow/util/cbcutil"
"go.mau.fi/whatsmeow/util/hkdfutil"
)
@@ -34,9 +35,14 @@ const (
MediaDocument MediaType = "WhatsApp Document Keys"
MediaHistory MediaType = "WhatsApp History Keys"
MediaAppState MediaType = "WhatsApp App State Keys"
MediaLinkThumbnail MediaType = "WhatsApp Link Thumbnail Keys"
)
// DownloadableMessage represents a protobuf message that contains attachment info.
//
// All of the downloadable messages inside a Message struct implement this interface
// (ImageMessage, VideoMessage, AudioMessage, DocumentMessage, StickerMessage).
type DownloadableMessage interface {
proto.Message
GetDirectPath() string
@@ -45,15 +51,27 @@ type DownloadableMessage interface {
GetFileEncSha256() []byte
}
// DownloadableThumbnail represents a protobuf message that contains a thumbnail attachment.
//
// This is primarily meant for link preview thumbnails in ExtendedTextMessage.
type DownloadableThumbnail interface {
proto.Message
GetThumbnailDirectPath() string
GetThumbnailSha256() []byte
GetThumbnailEncSha256() []byte
GetMediaKey() []byte
}
// All the message types that are intended to be downloadable
var (
_ DownloadableMessage = (*waProto.ImageMessage)(nil)
_ DownloadableMessage = (*waProto.AudioMessage)(nil)
_ DownloadableMessage = (*waProto.VideoMessage)(nil)
_ DownloadableMessage = (*waProto.DocumentMessage)(nil)
_ DownloadableMessage = (*waProto.StickerMessage)(nil)
_ DownloadableMessage = (*waProto.HistorySyncNotification)(nil)
_ DownloadableMessage = (*waProto.ExternalBlobReference)(nil)
_ DownloadableMessage = (*waProto.ImageMessage)(nil)
_ DownloadableMessage = (*waProto.AudioMessage)(nil)
_ DownloadableMessage = (*waProto.VideoMessage)(nil)
_ DownloadableMessage = (*waProto.DocumentMessage)(nil)
_ DownloadableMessage = (*waProto.StickerMessage)(nil)
_ DownloadableMessage = (*waProto.HistorySyncNotification)(nil)
_ DownloadableMessage = (*waProto.ExternalBlobReference)(nil)
_ DownloadableThumbnail = (*waProto.ExtendedTextMessage)(nil)
)
type downloadableMessageWithLength interface {
@@ -82,6 +100,10 @@ var classToMediaType = map[protoreflect.Name]MediaType{
"ExternalBlobReference": MediaAppState,
}
var classToThumbnailMediaType = map[protoreflect.Name]MediaType{
"ExtendedTextMessage": MediaLinkThumbnail,
}
var mediaTypeToMMSType = map[MediaType]string{
MediaImage: "image",
MediaAudio: "audio",
@@ -89,17 +111,29 @@ var mediaTypeToMMSType = map[MediaType]string{
MediaDocument: "document",
MediaHistory: "md-msg-hist",
MediaAppState: "md-app-state",
MediaLinkThumbnail: "thumbnail-link",
}
// DownloadAny loops through the downloadable parts of the given message and downloads the first non-nil item.
func (cli *Client) DownloadAny(msg *waProto.Message) (data []byte, err error) {
downloadables := []DownloadableMessage{msg.GetImageMessage(), msg.GetAudioMessage(), msg.GetVideoMessage(), msg.GetDocumentMessage(), msg.GetStickerMessage()}
for _, downloadable := range downloadables {
if downloadable != nil {
return cli.Download(downloadable)
}
if msg == nil {
return nil, ErrNothingDownloadableFound
}
switch {
case msg.ImageMessage != nil:
return cli.Download(msg.ImageMessage)
case msg.VideoMessage != nil:
return cli.Download(msg.VideoMessage)
case msg.AudioMessage != nil:
return cli.Download(msg.AudioMessage)
case msg.DocumentMessage != nil:
return cli.Download(msg.DocumentMessage)
case msg.StickerMessage != nil:
return cli.Download(msg.StickerMessage)
default:
return nil, ErrNothingDownloadableFound
}
return nil, ErrNothingDownloadableFound
}
func getSize(msg DownloadableMessage) int {
@@ -113,30 +147,63 @@ func getSize(msg DownloadableMessage) int {
}
}
// DownloadThumbnail downloads a thumbnail from a message.
//
// This is primarily intended for downloading link preview thumbnails, which are in ExtendedTextMessage:
// var msg *waProto.Message
// ...
// thumbnailImageBytes, err := cli.DownloadThumbnail(msg.GetExtendedTextMessage())
func (cli *Client) DownloadThumbnail(msg DownloadableThumbnail) ([]byte, error) {
mediaType, ok := classToThumbnailMediaType[msg.ProtoReflect().Descriptor().Name()]
if !ok {
return nil, fmt.Errorf("%w '%s'", ErrUnknownMediaType, string(msg.ProtoReflect().Descriptor().Name()))
} else if len(msg.GetThumbnailDirectPath()) > 0 {
return cli.DownloadMediaWithPath(msg.GetThumbnailDirectPath(), msg.GetThumbnailEncSha256(), msg.GetThumbnailSha256(), msg.GetMediaKey(), -1, mediaType, mediaTypeToMMSType[mediaType])
} else {
return nil, ErrNoURLPresent
}
}
// GetMediaType returns the MediaType value corresponding to the given protobuf message.
func GetMediaType(msg DownloadableMessage) MediaType {
return classToMediaType[msg.ProtoReflect().Descriptor().Name()]
}
// Download downloads the attachment from the given protobuf message.
func (cli *Client) Download(msg DownloadableMessage) (data []byte, err error) {
//
// The attachment is a specific part of a Message protobuf struct, not the message itself, e.g.
// var msg *waProto.Message
// ...
// imageData, err := cli.Download(msg.GetImageMessage())
//
// You can also use DownloadAny to download the first non-nil sub-message.
func (cli *Client) Download(msg DownloadableMessage) ([]byte, error) {
mediaType, ok := classToMediaType[msg.ProtoReflect().Descriptor().Name()]
if !ok {
return nil, fmt.Errorf("%w '%s'", ErrUnknownMediaType, string(msg.ProtoReflect().Descriptor().Name()))
}
urlable, ok := msg.(downloadableMessageWithURL)
if ok && len(urlable.GetUrl()) > 0 {
return downloadAndDecrypt(urlable.GetUrl(), msg.GetMediaKey(), mediaType, getSize(msg), msg.GetFileEncSha256(), msg.GetFileSha256())
return cli.downloadAndDecrypt(urlable.GetUrl(), msg.GetMediaKey(), mediaType, getSize(msg), msg.GetFileEncSha256(), msg.GetFileSha256())
} else if len(msg.GetDirectPath()) > 0 {
return cli.downloadMediaWithPath(msg.GetDirectPath(), msg.GetFileEncSha256(), msg.GetFileSha256(), msg.GetMediaKey(), getSize(msg), mediaType, mediaTypeToMMSType[mediaType])
return cli.DownloadMediaWithPath(msg.GetDirectPath(), msg.GetFileEncSha256(), msg.GetFileSha256(), msg.GetMediaKey(), getSize(msg), mediaType, mediaTypeToMMSType[mediaType])
} else {
return nil, ErrNoURLPresent
}
}
func (cli *Client) downloadMediaWithPath(directPath string, encFileHash, fileHash, mediaKey []byte, fileLength int, mediaType MediaType, mmsType string) (data []byte, err error) {
// DownloadMediaWithPath downloads an attachment by manually specifying the path and encryption details.
func (cli *Client) DownloadMediaWithPath(directPath string, encFileHash, fileHash, mediaKey []byte, fileLength int, mediaType MediaType, mmsType string) (data []byte, err error) {
err = cli.refreshMediaConn(false)
if err != nil {
return nil, fmt.Errorf("failed to refresh media connections: %w", err)
}
if len(mmsType) == 0 {
mmsType = mediaTypeToMMSType[mediaType]
}
for i, host := range cli.mediaConn.Hosts {
mediaURL := fmt.Sprintf("https://%s%s&hash=%s&mms-type=%s&__wa-mms=", host.Hostname, directPath, base64.URLEncoding.EncodeToString(encFileHash), mmsType)
data, err = downloadAndDecrypt(mediaURL, mediaKey, mediaType, fileLength, encFileHash, fileHash)
data, err = cli.downloadAndDecrypt(mediaURL, mediaKey, mediaType, fileLength, encFileHash, fileHash)
// TODO there are probably some errors that shouldn't retry
if err != nil {
if i >= len(cli.mediaConn.Hosts)-1 {
@@ -148,10 +215,10 @@ func (cli *Client) downloadMediaWithPath(directPath string, encFileHash, fileHas
return
}
func downloadAndDecrypt(url string, mediaKey []byte, appInfo MediaType, fileLength int, fileEncSha256, fileSha256 []byte) (data []byte, err error) {
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 = downloadEncryptedMedia(url, fileEncSha256); err != nil {
if ciphertext, mac, err = cli.downloadEncryptedMedia(url, fileEncSha256); err != nil {
} else if err = validateMedia(iv, ciphertext, macKey, mac); err != nil {
@@ -170,9 +237,17 @@ func getMediaKeys(mediaKey []byte, appInfo MediaType) (iv, cipherKey, macKey, re
return mediaKeyExpanded[:16], mediaKeyExpanded[16:48], mediaKeyExpanded[48:80], mediaKeyExpanded[80:]
}
func downloadEncryptedMedia(url string, checksum []byte) (file, mac []byte, err error) {
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)
if err != nil {
err = fmt.Errorf("failed to prepare request: %w", err)
return
}
req.Header.Set("Origin", socket.Origin)
req.Header.Set("Referer", socket.Origin+"/")
var resp *http.Response
resp, err = http.Get(url)
resp, err = cli.http.Do(req)
if err != nil {
return
}

View File

@@ -45,6 +45,8 @@ var (
ErrInviteLinkRevoked = errors.New("that group invite link has been revoked")
// ErrBusinessMessageLinkNotFound is returned by ResolveBusinessMessageLink if the link doesn't exist or has been revoked.
ErrBusinessMessageLinkNotFound = errors.New("that business message link does not exist or has been revoked")
// ErrInvalidImageFormat is returned by SetGroupPhoto if the given photo is not in the correct format.
ErrInvalidImageFormat = errors.New("the given data is not a valid image")
)
// Some errors that Client.SendMessage can return

View File

@@ -106,6 +106,40 @@ func (cli *Client) UpdateGroupParticipants(jid types.JID, participantChanges map
return resp, nil
}
// SetGroupPhoto updates the group picture/icon of the given group on WhatsApp.
// The avatar should be a JPEG photo, other formats may be rejected with ErrInvalidImageFormat.
// The bytes can be nil to remove the photo. Returns the new picture ID.
func (cli *Client) SetGroupPhoto(jid types.JID, avatar []byte) (string, error) {
var content interface{}
if avatar != nil {
content = []waBinary.Node{{
Tag: "picture",
Attrs: waBinary.Attrs{"type": "image"},
Content: avatar,
}}
}
resp, err := cli.sendIQ(infoQuery{
Namespace: "w:profile:picture",
Type: iqSet,
To: types.ServerJID,
Target: jid,
Content: content,
})
if errors.Is(err, ErrIQNotAcceptable) {
return "", wrapIQError(ErrInvalidImageFormat, err)
} else if err != nil {
return "", err
}
if avatar == nil {
return "remove", nil
}
pictureID, ok := resp.GetChildByTag("picture").Attrs["id"].(string)
if !ok {
return "", fmt.Errorf("didn't find picture ID in response")
}
return pictureID, nil
}
// SetGroupName updates the name (subject) of the given group on WhatsApp.
func (cli *Client) SetGroupName(jid types.JID, name string) error {
_, err := cli.sendGroupIQ(iqSet, jid, waBinary.Node{
@@ -385,7 +419,8 @@ func (cli *Client) parseGroupNode(groupNode *waBinary.Node) (*types.GroupInfo, e
case "description":
body, bodyOK := child.GetOptionalChildByTag("body")
if bodyOK {
group.Topic, _ = body.Content.(string)
topicBytes, _ := body.Content.([]byte)
group.Topic = string(topicBytes)
group.TopicID = childAG.String("id")
group.TopicSetBy = childAG.OptionalJIDOrEmpty("participant")
group.TopicSetAt = time.Unix(childAG.Int64("t"), 0)

163
vendor/go.mau.fi/whatsmeow/mediaretry.go vendored Normal file
View File

@@ -0,0 +1,163 @@
// Copyright (c) 2022 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 (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"time"
"google.golang.org/protobuf/proto"
waBinary "go.mau.fi/whatsmeow/binary"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"go.mau.fi/whatsmeow/util/hkdfutil"
)
func getMediaRetryKey(mediaKey []byte) (cipherKey []byte) {
return hkdfutil.SHA256(mediaKey, nil, []byte("WhatsApp Media Retry Notification"), 32)
}
func prepareMediaRetryGCM(mediaKey []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(getMediaRetryKey(mediaKey))
if err != nil {
return nil, fmt.Errorf("failed to initialize AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to initialize GCM: %w", err)
}
return gcm, nil
}
func encryptMediaRetryReceipt(messageID types.MessageID, mediaKey []byte) (ciphertext, iv []byte, err error) {
receipt := &waProto.ServerErrorReceipt{
StanzaId: proto.String(messageID),
}
var plaintext []byte
plaintext, err = proto.Marshal(receipt)
if err != nil {
err = fmt.Errorf("failed to marshal payload: %w", err)
return
}
var gcm cipher.AEAD
gcm, err = prepareMediaRetryGCM(mediaKey)
if err != nil {
return
}
iv = make([]byte, 12)
_, err = rand.Read(iv)
if err != nil {
panic(err)
}
ciphertext = gcm.Seal(plaintext[:0], iv, plaintext, []byte(messageID))
return
}
// SendMediaRetryReceipt sends a request to the phone to re-upload the media in a message.
//
// The response will come as an *events.MediaRetry. The response will then have to be decrypted
// using DecryptMediaRetryNotification and the same media key passed here.
func (cli *Client) SendMediaRetryReceipt(message *types.MessageInfo, mediaKey []byte) error {
ciphertext, iv, err := encryptMediaRetryReceipt(message.ID, mediaKey)
if err != nil {
return fmt.Errorf("failed to prepare encrypted retry receipt: %w", err)
}
rmrAttrs := waBinary.Attrs{
"jid": message.Chat,
"from_me": message.IsFromMe,
}
if message.IsGroup {
rmrAttrs["participant"] = message.Sender
}
encryptedRequest := []waBinary.Node{
{Tag: "enc_p", Content: ciphertext},
{Tag: "enc_iv", Content: iv},
}
err = cli.sendNode(waBinary.Node{
Tag: "receipt",
Attrs: waBinary.Attrs{
"id": message.ID,
"to": cli.Store.ID.ToNonAD(),
"type": "server-error",
},
Content: []waBinary.Node{
{Tag: "encrypt", Content: encryptedRequest},
{Tag: "rmr", Attrs: rmrAttrs},
},
})
if err != nil {
return err
}
return nil
}
// DecryptMediaRetryNotification decrypts a media retry notification using the media key.
func DecryptMediaRetryNotification(evt *events.MediaRetry, mediaKey []byte) (*waProto.MediaRetryNotification, error) {
gcm, err := prepareMediaRetryGCM(mediaKey)
if err != nil {
return nil, err
}
plaintext, err := gcm.Open(nil, evt.IV, evt.Ciphertext, []byte(evt.MessageID))
if err != nil {
return nil, fmt.Errorf("failed to decrypt notification: %w", err)
}
var notif waProto.MediaRetryNotification
err = proto.Unmarshal(plaintext, &notif)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal notification (invalid encryption key?): %w", err)
}
return &notif, nil
}
func parseMediaRetryNotification(node *waBinary.Node) (*events.MediaRetry, error) {
ag := node.AttrGetter()
var evt events.MediaRetry
evt.Timestamp = time.Unix(ag.Int64("t"), 0)
evt.MessageID = types.MessageID(ag.String("id"))
if !ag.OK() {
return nil, ag.Error()
}
rmr, ok := node.GetOptionalChildByTag("rmr")
if !ok {
return nil, &ElementMissingError{Tag: "rmr", In: "retry notification"}
}
rmrAG := rmr.AttrGetter()
evt.ChatID = rmrAG.JID("jid")
evt.FromMe = rmrAG.Bool("from_me")
evt.SenderID = rmrAG.OptionalJIDOrEmpty("participant")
if !rmrAG.OK() {
return nil, fmt.Errorf("missing attributes in <rmr> tag: %w", rmrAG.Error())
}
evt.Ciphertext, ok = node.GetChildByTag("encrypt", "enc_p").Content.([]byte)
if !ok {
return nil, &ElementMissingError{Tag: "enc_p", In: fmt.Sprintf("retry notification %s", evt.MessageID)}
}
evt.IV, ok = node.GetChildByTag("encrypt", "enc_iv").Content.([]byte)
if !ok {
return nil, &ElementMissingError{Tag: "enc_iv", In: fmt.Sprintf("retry notification %s", evt.MessageID)}
}
return &evt, nil
}
func (cli *Client) handleMediaRetryNotification(node *waBinary.Node) {
// TODO handle errors (e.g. <error code="2"/>)
evt, err := parseMediaRetryNotification(node)
if err != nil {
cli.Log.Warnf("Failed to parse media retry notification: %v", err)
return
}
cli.dispatchEvent(evt)
}

View File

@@ -283,6 +283,7 @@ func (cli *Client) handleHistorySyncNotification(notif *waProto.HistorySyncNotif
}
func (cli *Client) handleAppStateSyncKeyShare(keys *waProto.AppStateSyncKeyShare) {
cli.Log.Debugf("Got %d new app state keys", len(keys.GetKeys()))
for _, key := range keys.GetKeys() {
marshaledFingerprint, err := proto.Marshal(key.GetKeyData().GetFingerprint())
if err != nil {
@@ -365,7 +366,7 @@ func (cli *Client) handleDecryptedMessage(info *types.MessageInfo, msg *waProto.
}
func (cli *Client) sendProtocolMessageReceipt(id, msgType string) {
if len(id) == 0 {
if len(id) == 0 || cli.Store.ID == nil {
return
}
err := cli.sendNode(waBinary.Node{

View File

@@ -199,6 +199,9 @@ func (cli *Client) handleNotification(node *waBinary.Node) {
}
case "picture":
go cli.handlePictureNotification(node)
case "mediaretry":
go cli.handleMediaRetryNotification(node)
// Other types: business, disappearing_mode, server, status, pay, psa, privacy_token
default:
cli.Log.Debugf("Unhandled notification with type %s", notifType)
}

View File

@@ -7,6 +7,7 @@
package whatsmeow
import (
"sync/atomic"
"time"
waBinary "go.mau.fi/whatsmeow/binary"
@@ -23,12 +24,14 @@ func (cli *Client) handleChatState(node *waBinary.Node) {
} else {
child := node.GetChildren()[0]
presence := types.ChatPresence(child.Tag)
if presence != types.ChatPresenceComposing && presence != types.ChatPresenceRecording && presence != types.ChatPresencePaused {
if presence != types.ChatPresenceComposing && presence != types.ChatPresencePaused {
cli.Log.Warnf("Unrecognized chat presence state %s", child.Tag)
}
media := types.ChatPresenceMedia(child.AttrGetter().OptionalString("media"))
cli.dispatchEvent(&events.ChatPresence{
MessageSource: source,
State: presence,
Media: media,
})
}
}
@@ -62,6 +65,11 @@ func (cli *Client) SendPresence(state types.Presence) error {
if len(cli.Store.PushName) == 0 {
return ErrNoPushName
}
if state == types.PresenceAvailable {
atomic.CompareAndSwapUint32(&cli.sendActiveReceipts, 0, 1)
} else {
atomic.CompareAndSwapUint32(&cli.sendActiveReceipts, 1, 0)
}
return cli.sendNode(waBinary.Node{
Tag: "presence",
Attrs: waBinary.Attrs{
@@ -89,13 +97,21 @@ func (cli *Client) SubscribePresence(jid types.JID) error {
}
// SendChatPresence updates the user's typing status in a specific chat.
func (cli *Client) SendChatPresence(state types.ChatPresence, jid types.JID) error {
//
// The media parameter can be set to indicate the user is recording media (like a voice message) rather than typing a text message.
func (cli *Client) SendChatPresence(jid types.JID, state types.ChatPresence, media types.ChatPresenceMedia) error {
content := []waBinary.Node{{Tag: string(state)}}
if state == types.ChatPresenceComposing && len(media) > 0 {
content[0].Attrs = waBinary.Attrs{
"media": string(media),
}
}
return cli.sendNode(waBinary.Node{
Tag: "chatstate",
Attrs: waBinary.Attrs{
"from": *cli.Store.ID,
"to": jid,
},
Content: []waBinary.Node{{Tag: string(state)}},
Content: content,
})
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2021 Tulir Asokan
// Copyright (c) 2022 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
@@ -36,6 +36,8 @@ var (
// QRChannelErrUnexpectedEvent is emitted from GetQRChannel if an unexpected connection event is received,
// as that likely means that the pairing has already happened before the channel was set up.
QRChannelErrUnexpectedEvent = QRChannelItem{Event: "err-unexpected-state"}
// QRChannelClientOutdated is emitted from GetQRChannel if events.ClientOutdated is received.
QRChannelClientOutdated = QRChannelItem{Event: "err-client-outdated"}
// QRChannelScannedWithoutMultidevice is emitted from GetQRChannel if events.QRScannedWithoutMultidevice is received.
QRChannelScannedWithoutMultidevice = QRChannelItem{Event: "err-scanned-without-multidevice"}
)
@@ -117,6 +119,8 @@ func (qrc *qrChannel) handleEvent(rawEvt interface{}) {
qrc.log.Debugf("QR code scanned without multidevice enabled")
qrc.output <- QRChannelScannedWithoutMultidevice
return
case *events.ClientOutdated:
outputType = QRChannelClientOutdated
case *events.PairSuccess:
outputType = QRChannelSuccess
case *events.PairError:
@@ -126,7 +130,7 @@ func (qrc *qrChannel) handleEvent(rawEvt interface{}) {
}
case *events.Disconnected:
outputType = QRChannelTimeout
case *events.Connected, *events.ConnectFailure, *events.LoggedOut:
case *events.Connected, *events.ConnectFailure, *events.LoggedOut, *events.TemporaryBan:
outputType = QRChannelErrUnexpectedEvent
default:
return

View File

@@ -8,6 +8,7 @@ package whatsmeow
import (
"fmt"
"sync/atomic"
"time"
waBinary "go.mau.fi/whatsmeow/binary"
@@ -123,13 +124,35 @@ func (cli *Client) MarkRead(ids []types.MessageID, timestamp time.Time, chat, se
return cli.sendNode(node)
}
// SetForceActiveDeliveryReceipts will force the client to send normal delivery
// receipts (which will show up as the two gray ticks on WhatsApp), even if the
// client isn't marked as online.
//
// By default, clients that haven't been marked as online will send delivery
// receipts with type="inactive", which is transmitted to the sender, but not
// rendered in the official WhatsApp apps. This is consistent with how WhatsApp
// web works when it's not in the foreground.
//
// To mark the client as online, use
// cli.SendPresence(types.PresenceAvailable)
//
// Note that if you turn this off (i.e. call SetForceActiveDeliveryReceipts(false)),
// receipts will act like the client is offline until SendPresence is called again.
func (cli *Client) SetForceActiveDeliveryReceipts(active bool) {
if active {
atomic.StoreUint32(&cli.sendActiveReceipts, 2)
} else {
atomic.StoreUint32(&cli.sendActiveReceipts, 0)
}
}
func (cli *Client) sendMessageReceipt(info *types.MessageInfo) {
attrs := waBinary.Attrs{
"id": info.ID,
}
if info.IsFromMe {
attrs["type"] = "sender"
} else {
} else if atomic.LoadUint32(&cli.sendActiveReceipts) == 0 {
attrs["type"] = "inactive"
}
attrs["to"] = info.Chat
@@ -137,6 +160,9 @@ func (cli *Client) sendMessageReceipt(info *types.MessageInfo) {
attrs["participant"] = info.Sender
} else if info.IsFromMe {
attrs["recipient"] = info.Sender
} else {
// Override the to attribute with the JID version with a device number
attrs["to"] = info.Sender
}
err := cli.sendNode(waBinary.Node{
Tag: "receipt",

View File

@@ -78,6 +78,7 @@ type infoQuery struct {
Namespace string
Type infoQueryType
To types.JID
Target types.JID
ID string
Content interface{}
@@ -98,6 +99,9 @@ func (cli *Client) sendIQAsync(query infoQuery) (<-chan *waBinary.Node, error) {
if !query.To.IsEmpty() {
attrs["to"] = query.To
}
if !query.Target.IsEmpty() {
attrs["target"] = query.Target
}
err := cli.sendNode(waBinary.Node{
Tag: "iq",
Attrs: attrs,
@@ -116,7 +120,7 @@ func (cli *Client) sendIQ(query infoQuery) (*waBinary.Node, error) {
return nil, err
}
if query.Timeout == 0 {
query.Timeout = 1 * time.Minute
query.Timeout = 75 * time.Second
}
if query.Context == nil {
query.Context = context.Background()

View File

@@ -30,6 +30,9 @@ import (
)
// GenerateMessageID generates a random string that can be used as a message ID on WhatsApp.
//
// msgID := whatsmeow.GenerateMessageID()
// cli.SendMessage(targetJID, msgID, &waProto.Message{...})
func GenerateMessageID() types.MessageID {
id := make([]byte, 16)
_, err := rand.Read(id)
@@ -46,6 +49,20 @@ func GenerateMessageID() types.MessageID {
//
// This method will wait for the server to acknowledge the message before returning.
// The return value is the timestamp of the message from the server.
//
// The message itself can contain anything you want (within the protobuf schema).
// e.g. for a simple text message, use the Conversation field:
// cli.SendMessage(targetJID, "", &waProto.Message{
// Conversation: proto.String("Hello, World!"),
// })
//
// Things like replies, mentioning users and the "forwarded" flag are stored in ContextInfo,
// which can be put in ExtendedTextMessage and any of the media message types.
//
// For uploading and sending media/attachments, see the Upload method.
//
// For other message types, you'll have to figure it out yourself. Looking at the protobuf schema
// in binary/proto/def.proto may be useful to find out all the allowed fields.
func (cli *Client) SendMessage(to types.JID, id types.MessageID, message *waProto.Message) (time.Time, error) {
if to.AD {
return time.Time{}, ErrRecipientADJID
@@ -210,7 +227,7 @@ func (cli *Client) prepareMessageNode(to types.JID, id types.MessageID, message
Content: participantNodes,
}},
}
if message.ProtocolMessage != nil && message.GetProtocolMessage().GetType() == waProto.ProtocolMessage_REVOKE {
if message.ProtocolMessage != nil && message.GetProtocolMessage().GetType() == waProto.ProtocolMessage_REVOKE && message.GetProtocolMessage().GetKey() != nil {
node.Attrs["edit"] = "7"
}
if includeIdentity {

View File

@@ -10,7 +10,11 @@
// The Client struct in the top-level whatsmeow package handles everything.
package socket
import "errors"
import (
"errors"
"go.mau.fi/whatsmeow/binary/token"
)
const (
// Origin is the Origin header for all WhatsApp websocket connections
@@ -22,11 +26,10 @@ const (
const (
NoiseStartPattern = "Noise_XX_25519_AESGCM_SHA256\x00\x00\x00\x00"
WADictVersion = 2
WAMagicValue = 5
WAMagicValue = 5
)
var WAConnHeader = []byte{'W', 'A', WAMagicValue, WADictVersion}
var WAConnHeader = []byte{'W', 'A', WAMagicValue, token.DictVersion}
const (
FrameMaxSize = 2 << 23

View File

@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"sync"
"time"
@@ -19,6 +20,8 @@ import (
waLog "go.mau.fi/whatsmeow/util/log"
)
type Proxy = func(*http.Request) (*url.URL, error)
type FrameSocket struct {
conn *websocket.Conn
ctx context.Context
@@ -31,6 +34,7 @@ type FrameSocket struct {
WriteTimeout time.Duration
Header []byte
Proxy Proxy
incomingLength int
receivedLength int
@@ -38,12 +42,14 @@ type FrameSocket struct {
partialHeader []byte
}
func NewFrameSocket(log waLog.Logger, header []byte) *FrameSocket {
func NewFrameSocket(log waLog.Logger, header []byte, proxy Proxy) *FrameSocket {
return &FrameSocket{
conn: nil,
log: log,
Header: header,
Frames: make(chan []byte),
Proxy: proxy,
}
}
@@ -92,7 +98,9 @@ func (fs *FrameSocket) Connect() error {
return ErrSocketAlreadyOpen
}
ctx, cancel := context.WithCancel(context.Background())
dialer := websocket.Dialer{}
dialer := websocket.Dialer{
Proxy: fs.Proxy,
}
headers := http.Header{"Origin": []string{Origin}}
fs.log.Debugf("Dialing %s", URL)

View File

@@ -20,36 +20,98 @@ import (
waProto "go.mau.fi/whatsmeow/binary/proto"
)
// WAVersionContainer is a container for a WhatsApp web version number.
type WAVersionContainer [3]uint32
// ParseVersion parses a version string (three dot-separated numbers) into a WAVersionContainer.
func ParseVersion(version string) (parsed WAVersionContainer, err error) {
var part1, part2, part3 int
if parts := strings.Split(version, "."); len(parts) != 3 {
err = fmt.Errorf("'%s' doesn't contain three dot-separated parts", version)
} else if part1, err = strconv.Atoi(parts[0]); err != nil {
err = fmt.Errorf("first part of '%s' is not a number: %w", version, err)
} else if part2, err = strconv.Atoi(parts[1]); err != nil {
err = fmt.Errorf("second part of '%s' is not a number: %w", version, err)
} else if part3, err = strconv.Atoi(parts[2]); err != nil {
err = fmt.Errorf("third part of '%s' is not a number: %w", version, err)
} else {
parsed = WAVersionContainer{uint32(part1), uint32(part2), uint32(part3)}
}
return
}
func (vc WAVersionContainer) LessThan(other WAVersionContainer) bool {
return vc[0] < other[0] ||
(vc[0] == other[0] && vc[1] < other[1]) ||
(vc[0] == other[0] && vc[1] == other[1] && vc[2] < other[2])
}
// IsZero returns true if the version is zero.
func (vc WAVersionContainer) IsZero() bool {
return vc == [3]uint32{0, 0, 0}
}
// String returns the version number as a dot-separated string.
func (vc WAVersionContainer) String() string {
parts := make([]string, len(vc))
for i, part := range vc {
parts[i] = strconv.Itoa(int(part))
}
return strings.Join(parts, ".")
}
// Hash returns the md5 hash of the String representation of this version.
func (vc WAVersionContainer) Hash() [16]byte {
return md5.Sum([]byte(vc.String()))
}
func (vc WAVersionContainer) ProtoAppVersion() *waProto.AppVersion {
return &waProto.AppVersion{
Primary: &vc[0],
Secondary: &vc[1],
Tertiary: &vc[2],
}
}
// waVersion is the WhatsApp web client version
var waVersion = []uint32{2, 2202, 9}
var waVersion = WAVersionContainer{2, 2208, 7}
// waVersionHash is the md5 hash of a dot-separated waVersion
var waVersionHash [16]byte
func init() {
waVersionParts := make([]string, len(waVersion))
for i, part := range waVersion {
waVersionParts[i] = strconv.Itoa(int(part))
waVersionHash = waVersion.Hash()
}
// GetWAVersion gets the current WhatsApp web client version.
func GetWAVersion() WAVersionContainer {
return waVersion
}
// SetWAVersion sets the current WhatsApp web client version.
//
// In general, you should keep the library up-to-date instead of using this,
// as there may be code changes that are necessary too (like protobuf schema changes).
func SetWAVersion(version WAVersionContainer) {
if version.IsZero() {
return
}
waVersionString := strings.Join(waVersionParts, ".")
waVersionHash = md5.Sum([]byte(waVersionString))
waVersion = version
waVersionHash = version.Hash()
}
var BaseClientPayload = &waProto.ClientPayload{
UserAgent: &waProto.UserAgent{
Platform: waProto.UserAgent_WEB.Enum(),
ReleaseChannel: waProto.UserAgent_RELEASE.Enum(),
AppVersion: &waProto.AppVersion{
Primary: &waVersion[0],
Secondary: &waVersion[1],
Tertiary: &waVersion[2],
},
Mcc: proto.String("000"),
Mnc: proto.String("000"),
OsVersion: proto.String("0.1.0"),
Manufacturer: proto.String(""),
Device: proto.String("Desktop"),
OsBuildNumber: proto.String("0.1.0"),
AppVersion: waVersion.ProtoAppVersion(),
Mcc: proto.String("000"),
Mnc: proto.String("000"),
OsVersion: proto.String("0.1.0"),
Manufacturer: proto.String(""),
Device: proto.String("Desktop"),
OsBuildNumber: proto.String("0.1.0"),
LocaleLanguageIso6391: proto.String("en"),
LocaleCountryIso31661Alpha2: proto.String("en"),
},

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2021 Tulir Asokan
// Copyright (c) 2022 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
@@ -349,15 +349,15 @@ func (s *SQLStore) putAppStateMutationMACs(tx execable, name string, version uin
values[0] = s.JID
values[1] = name
values[2] = version
placeholderSyntax := "($1, $2, $3, $%d, $%d)"
if s.dialect == "sqlite3" {
placeholderSyntax = "(?1, ?2, ?3, ?%d, ?%d)"
}
for i, mutation := range mutations {
baseIndex := 3 + i*2
values[baseIndex] = mutation.IndexMAC
values[baseIndex+1] = mutation.ValueMAC
if s.dialect == "sqlite3" {
queryParts[i] = fmt.Sprintf("(?1, ?2, ?3, ?%d, ?%d)", baseIndex+1, baseIndex+2)
} else {
queryParts[i] = fmt.Sprintf("($1, $2, $3, $%d, $%d)", baseIndex+1, baseIndex+2)
}
queryParts[i] = fmt.Sprintf(placeholderSyntax, baseIndex+1, baseIndex+2)
}
_, err := tx.Exec(putAppStateMutationMACsQuery+strings.Join(queryParts, ","), values...)
return err
@@ -426,7 +426,12 @@ func (s *SQLStore) GetAppStateMutationMAC(name string, indexMAC []byte) (valueMA
const (
putContactNameQuery = `
INSERT INTO whatsmeow_contacts (our_jid, their_jid, first_name, full_name) VALUES ($1, $2, $3, $4)
ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=$3, full_name=$4
ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=excluded.first_name, full_name=excluded.full_name
`
putManyContactNamesQuery = `
INSERT INTO whatsmeow_contacts (our_jid, their_jid, first_name, full_name)
VALUES %s
ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=excluded.first_name, full_name=excluded.full_name
`
putPushNameQuery = `
INSERT INTO whatsmeow_contacts (our_jid, their_jid, push_name) VALUES ($1, $2, $3)
@@ -504,6 +509,77 @@ func (s *SQLStore) PutContactName(user types.JID, firstName, fullName string) er
return nil
}
const contactBatchSize = 300
func (s *SQLStore) putContactNamesBatch(tx execable, contacts []store.ContactEntry) error {
values := make([]interface{}, 1, 1+len(contacts)*3)
queryParts := make([]string, 0, len(contacts))
values[0] = s.JID
placeholderSyntax := "($1, $%d, $%d, $%d)"
if s.dialect == "sqlite3" {
placeholderSyntax = "(?1, ?%d, ?%d, ?%d)"
}
i := 0
handledContacts := make(map[types.JID]struct{}, len(contacts))
for _, contact := range contacts {
if contact.JID.IsEmpty() {
s.log.Warnf("Empty contact info in mass insert: %+v", contact)
continue
}
// The whole query will break if there are duplicates, so make sure there aren't any duplicates
_, alreadyHandled := handledContacts[contact.JID]
if alreadyHandled {
s.log.Warnf("Duplicate contact info for %s in mass insert", contact.JID)
continue
}
handledContacts[contact.JID] = struct{}{}
baseIndex := i*3 + 1
values = append(values, contact.JID.String(), contact.FirstName, contact.FullName)
queryParts = append(queryParts, fmt.Sprintf(placeholderSyntax, baseIndex+1, baseIndex+2, baseIndex+3))
i++
}
_, err := tx.Exec(fmt.Sprintf(putManyContactNamesQuery, strings.Join(queryParts, ",")), values...)
return err
}
func (s *SQLStore) PutAllContactNames(contacts []store.ContactEntry) error {
if len(contacts) > contactBatchSize {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
for i := 0; i < len(contacts); i += contactBatchSize {
var contactSlice []store.ContactEntry
if len(contacts) > i+contactBatchSize {
contactSlice = contacts[i : i+contactBatchSize]
} else {
contactSlice = contacts[i:]
}
err = s.putContactNamesBatch(tx, contactSlice)
if err != nil {
_ = tx.Rollback()
return err
}
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
} else if len(contacts) > 0 {
err := s.putContactNamesBatch(s.db, contacts)
if err != nil {
return err
}
} else {
return nil
}
s.contactCacheLock.Lock()
// Just clear the cache, fetching pushnames and business names would be too much effort
s.contactCache = make(map[types.JID]*types.ContactInfo)
s.contactCacheLock.Unlock()
return nil
}
func (s *SQLStore) getContact(user types.JID) (*types.ContactInfo, error) {
cached, ok := s.contactCache[user]
if ok {

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2021 Tulir Asokan
// Copyright (c) 2022 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
@@ -71,10 +71,17 @@ type AppStateStore interface {
GetAppStateMutationMAC(name string, indexMAC []byte) (valueMAC []byte, err error)
}
type ContactEntry struct {
JID types.JID
FirstName string
FullName string
}
type ContactStore interface {
PutPushName(user types.JID, pushName string) (bool, string, error)
PutBusinessName(user types.JID, businessName string) error
PutContactName(user types.JID, fullName, firstName string) error
PutAllContactNames(contacts []ContactEntry) error
GetContact(user types.JID) (types.ContactInfo, error)
GetAllContacts() (map[types.JID]types.ContactInfo, error)
}

View File

@@ -76,6 +76,14 @@ type Archive struct {
Action *waProto.ArchiveChatAction // The current archival status of the chat.
}
// MarkChatAsRead is emitted when a whole chat is marked as read or unread from another device.
type MarkChatAsRead struct {
JID types.JID // The chat which was marked as read or unread.
Timestamp time.Time // The time when the marking happened.
Action *waProto.MarkChatAsReadAction // Whether the chat was marked as read or unread, and info about the most recent messages.
}
// PushNameSetting is emitted when the user's push name is changed from another device.
type PushNameSetting struct {
Timestamp time.Time // The time when the push name was changed.

View File

@@ -62,6 +62,8 @@ type LoggedOut struct {
// OnConnect is true if the event was triggered by a connect failure message.
// If it's false, the event was triggered by a stream:error message.
OnConnect bool
// If OnConnect is true, then this field contains the reason code.
Reason ConnectFailureReason
}
// StreamReplaced is emitted when the client is disconnected by another client connecting with the same keys.
@@ -70,14 +72,96 @@ type LoggedOut struct {
// or otherwise try to connect twice with the same session.
type StreamReplaced struct{}
// TempBanReason is an error code included in temp ban error events.
type TempBanReason int
const (
TempBanBlockedByUsers TempBanReason = 101
TempBanSentToTooManyPeople TempBanReason = 102
TempBanCreatedTooManyGroups TempBanReason = 103
TempBanSentTooManySameMessage TempBanReason = 104
TempBanBroadcastList TempBanReason = 106
)
var tempBanReasonMessage = map[TempBanReason]string{
TempBanBlockedByUsers: "too many people blocked you",
TempBanSentToTooManyPeople: "you sent too many messages to people who don't have you in their address books",
TempBanCreatedTooManyGroups: "you created too many groups with people who don't have you in their address books",
TempBanSentTooManySameMessage: "you sent the same message to too many people",
TempBanBroadcastList: "you sent too many messages to a broadcast list",
}
// String returns the reason code and a human-readable description of the ban reason.
func (tbr TempBanReason) String() string {
msg, ok := tempBanReasonMessage[tbr]
if !ok {
msg = "you may have violated the terms of service (unknown error)"
}
return fmt.Sprintf("%d: %s", int(tbr), msg)
}
// TemporaryBan is emitted when there's a connection failure with the ConnectFailureTempBanned reason code.
type TemporaryBan struct {
Code TempBanReason
Expire time.Time
}
func (tb *TemporaryBan) String() string {
if tb.Expire.IsZero() {
return fmt.Sprintf("You've been temporarily banned: %v", tb.Code)
}
return fmt.Sprintf("You've been temporarily banned: %v. The ban expires at %v", tb.Code, tb.Expire)
}
// ConnectFailureReason is an error code included in connection failure events.
type ConnectFailureReason int
const (
ConnectFailureLoggedOut ConnectFailureReason = 401
ConnectFailureTempBanned ConnectFailureReason = 402
ConnectFailureBanned ConnectFailureReason = 403
ConnectFailureUnknownLogout ConnectFailureReason = 406
ConnectFailureClientOutdated ConnectFailureReason = 405
ConnectFailureBadUserAgent ConnectFailureReason = 409
// 400, 500 and 501 are also existing codes, but the meaning is unknown
)
var connectFailureReasonMessage = map[ConnectFailureReason]string{
ConnectFailureLoggedOut: "logged out from another device",
ConnectFailureTempBanned: "account temporarily banned",
ConnectFailureBanned: "account banned from WhatsApp",
ConnectFailureUnknownLogout: "logged out for unknown reason",
ConnectFailureClientOutdated: "client is out of date",
ConnectFailureBadUserAgent: "client user agent was rejected",
}
// IsLoggedOut returns true if the client should delete session data due to this connect failure.
func (cfr ConnectFailureReason) IsLoggedOut() bool {
return cfr == ConnectFailureLoggedOut || cfr == ConnectFailureBanned || cfr == ConnectFailureUnknownLogout
}
// String returns the reason code and a short human-readable description of the error.
func (cfr ConnectFailureReason) String() string {
msg, ok := connectFailureReasonMessage[cfr]
if !ok {
msg = "unknown error"
}
return fmt.Sprintf("%d: %s", int(cfr), msg)
}
// ConnectFailure is emitted when the WhatsApp server sends a <failure> node with an unknown reason.
//
// Known reasons are handled internally and emitted as different events (e.g. LoggedOut).
// Known reasons are handled internally and emitted as different events (e.g. LoggedOut and TemporaryBan).
type ConnectFailure struct {
Reason string
Reason ConnectFailureReason
Raw *waBinary.Node
}
// ClientOutdated is emitted when the WhatsApp server rejects the connection with the ConnectFailureClientOutdated code.
type ClientOutdated struct{}
// StreamError is emitted when the WhatsApp server sends a <stream:error> node with an unknown code.
//
// Known codes are handled internally and emitted as different events (e.g. LoggedOut).
@@ -165,7 +249,8 @@ type Receipt struct {
// client.SendPresence(types.PresenceAvailable)
type ChatPresence struct {
types.MessageSource
State types.ChatPresence
State types.ChatPresence // The current state, either composing or paused
Media types.ChatPresenceMedia // When composing, the type of message
}
// Presence is emitted when a presence update is received.
@@ -261,3 +346,16 @@ type OfflineSyncPreview struct {
type OfflineSyncCompleted struct {
Count int
}
// MediaRetry is emitted when the phone sends a response to a media retry request.
type MediaRetry struct {
Ciphertext []byte
IV []byte
Timestamp time.Time // The time of the response.
MessageID types.MessageID // The ID of the message.
ChatID types.JID // The chat ID where the message was sent.
SenderID types.JID // The user who sent the message. Only present in groups.
FromMe bool // Whether the message was sent by the current user or someone else.
}

View File

@@ -155,6 +155,21 @@ func (jid JID) String() string {
}
}
// MarshalText implements encoding.TextMarshaler for JID
func (jid JID) MarshalText() ([]byte, error) {
return []byte(jid.String()), nil
}
// UnmarshalText implements encoding.TextUnmarshaler for JID
func (jid *JID) UnmarshalText(val []byte) error {
out, err := ParseJID(string(val))
if err != nil {
return err
}
*jid = out
return nil
}
// IsEmpty returns true if the JID has no server (which is required for all JIDs).
func (jid JID) IsEmpty() bool {
return len(jid.Server) == 0

View File

@@ -17,6 +17,12 @@ type ChatPresence string
const (
ChatPresenceComposing ChatPresence = "composing"
ChatPresenceRecording ChatPresence = "recording"
ChatPresencePaused ChatPresence = "paused"
)
type ChatPresenceMedia string
const (
ChatPresenceMediaText ChatPresenceMedia = ""
ChatPresenceMediaAudio ChatPresenceMedia = "audio"
)

81
vendor/go.mau.fi/whatsmeow/update.go vendored Normal file
View File

@@ -0,0 +1,81 @@
// Copyright (c) 2022 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 (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"go.mau.fi/whatsmeow/socket"
"go.mau.fi/whatsmeow/store"
)
// CheckUpdateResponse is the data returned by CheckUpdate.
type CheckUpdateResponse struct {
IsBroken bool
IsBelowSoft bool
IsBelowHard bool
CurrentVersion string
ParsedVersion store.WAVersionContainer `json:"-"`
}
// CheckUpdateURL is the base URL to check for WhatsApp web updates.
const CheckUpdateURL = "https://web.whatsapp.com/check-update"
// CheckUpdate asks the WhatsApp servers if there is an update available
// (using the HTTP client and proxy settings of this whatsmeow Client instance).
func (cli *Client) CheckUpdate() (respData CheckUpdateResponse, err error) {
return CheckUpdate(http.DefaultClient)
}
// CheckUpdate asks the WhatsApp servers if there is an update available.
func CheckUpdate(httpClient *http.Client) (respData CheckUpdateResponse, err error) {
var reqURL *url.URL
reqURL, err = url.Parse(CheckUpdateURL)
if err != nil {
err = fmt.Errorf("failed to parse check update URL: %w", err)
return
}
q := reqURL.Query()
q.Set("version", store.GetWAVersion().String())
q.Set("platform", "web")
reqURL.RawQuery = q.Encode()
var req *http.Request
req, err = http.NewRequest(http.MethodGet, reqURL.String(), nil)
if err != nil {
err = fmt.Errorf("failed to prepare request: %w", err)
return
}
req.Header.Set("Origin", socket.Origin)
req.Header.Set("Referer", socket.Origin+"/")
var resp *http.Response
resp, err = httpClient.Do(req)
if err != nil {
err = fmt.Errorf("failed to send request: %w", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
err = fmt.Errorf("unexpected response with status %d: %s", resp.StatusCode, body)
return
}
err = json.NewDecoder(resp.Body).Decode(&respData)
if err != nil {
err = fmt.Errorf("failed to decode response body (status %d): %w", resp.StatusCode, err)
return
}
respData.ParsedVersion, err = store.ParseVersion(respData.CurrentVersion)
if err != nil {
err = fmt.Errorf("failed to parse version string: %w", err)
}
return
}

View File

@@ -24,16 +24,41 @@ import (
// UploadResponse contains the data from the attachment upload, which can be put into a message to send the attachment.
type UploadResponse struct {
URL string
DirectPath string
URL string `json:"url"`
DirectPath string `json:"direct_path"`
MediaKey []byte
FileEncSHA256 []byte
FileSHA256 []byte
FileLength uint64
MediaKey []byte `json:"-"`
FileEncSHA256 []byte `json:"-"`
FileSHA256 []byte `json:"-"`
FileLength uint64 `json:"-"`
}
// Upload uploads the given attachment to WhatsApp servers.
//
// You should copy the fields in the response to the corresponding fields in a protobuf message.
//
// For example, to send an image:
// resp, err := cli.Upload(context.Background(), yourImageBytes, whatsmeow.MediaImage)
// // handle error
//
// imageMsg := &waProto.ImageMessage{
// Caption: proto.String("Hello, world!"),
// Mimetype: proto.String("image/png"), // replace this with the actual mime type
// // you can also optionally add other fields like ContextInfo and JpegThumbnail here
//
// Url: &resp.URL,
// DirectPath: &uploaded.DirectPath,
// MediaKey: resp.MediaKey,
// FileEncSha256: resp.FileEncSHA256,
// FileSha256: resp.FileSha256,
// FileLength: &resp.FileLength,
// }
// _, err = cli.SendMessage(targetJID, "", &waProto.Message{
// ImageMessage: imageMsg,
// })
// // handle error again
//
// The same applies to the other message types like DocumentMessage, just replace the struct type and Message field name.
func (cli *Client) Upload(ctx context.Context, plaintext []byte, appInfo MediaType) (resp UploadResponse, err error) {
resp.FileLength = uint64(len(plaintext))
resp.MediaKey = make([]byte, 32)
@@ -92,7 +117,7 @@ func (cli *Client) Upload(ctx context.Context, plaintext []byte, appInfo MediaTy
req.Header.Set("Referer", socket.Origin+"/")
var httpResp *http.Response
httpResp, err = http.DefaultClient.Do(req)
httpResp, err = cli.http.Do(req)
if err != nil {
err = fmt.Errorf("failed to execute request: %w", err)
} else if httpResp.StatusCode != http.StatusOK {