diff options
Diffstat (limited to 'bridge')
-rw-r--r-- | bridge/whatsapp/handlers.go | 292 | ||||
-rw-r--r-- | bridge/whatsapp/helpers.go | 163 | ||||
-rw-r--r-- | bridge/whatsapp/whatsapp.go | 297 | ||||
-rw-r--r-- | bridge/whatsappmulti/handlers.go | 344 | ||||
-rw-r--r-- | bridge/whatsappmulti/helpers.go | 108 | ||||
-rw-r--r-- | bridge/whatsappmulti/whatsapp.go | 333 |
6 files changed, 1211 insertions, 326 deletions
diff --git a/bridge/whatsapp/handlers.go b/bridge/whatsapp/handlers.go index bac886d3..1be9b5e3 100644 --- a/bridge/whatsapp/handlers.go +++ b/bridge/whatsapp/handlers.go @@ -4,104 +4,126 @@ import ( "fmt" "mime" "strings" + "time" "github.com/42wim/matterbridge/bridge/config" "github.com/42wim/matterbridge/bridge/helper" - - "go.mau.fi/whatsmeow/binary/proto" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + "github.com/Rhymen/go-whatsapp" + "github.com/jpillora/backoff" ) -// nolint:gocritic -func (b *Bwhatsapp) eventHandler(evt interface{}) { - switch e := evt.(type) { - case *events.Message: - b.handleMessage(e) +/* +Implement handling messages coming from WhatsApp +Check: +- https://github.com/Rhymen/go-whatsapp#add-message-handlers +- https://github.com/Rhymen/go-whatsapp/blob/master/handler.go +- https://github.com/tulir/mautrix-whatsapp/tree/master/whatsapp-ext for more advanced command handling +*/ + +// HandleError received from WhatsApp +func (b *Bwhatsapp) HandleError(err error) { + // ignore received invalid data errors. https://github.com/42wim/matterbridge/issues/843 + // ignore tag 174 errors. https://github.com/42wim/matterbridge/issues/1094 + if strings.Contains(err.Error(), "error processing data: received invalid data") || + strings.Contains(err.Error(), "invalid string with tag 174") { + return + } + + switch err.(type) { + case *whatsapp.ErrConnectionClosed, *whatsapp.ErrConnectionFailed: + b.reconnect(err) + default: + switch err { + case whatsapp.ErrConnectionTimeout: + b.reconnect(err) + default: + b.Log.Errorf("%v", err) + } } } -func (b *Bwhatsapp) handleMessage(message *events.Message) { - msg := message.Message - switch { - case msg == nil, message.Info.IsFromMe, message.Info.Timestamp.Before(b.startedAt): - return +func (b *Bwhatsapp) reconnect(err error) { + bf := &backoff.Backoff{ + Min: time.Second, + Max: 5 * time.Minute, + Jitter: true, } - b.Log.Infof("Receiving message %#v", msg) + for { + d := bf.Duration() - switch { - case msg.Conversation != nil || msg.ExtendedTextMessage != nil: - b.handleTextMessage(message.Info, msg) - case msg.VideoMessage != nil: - b.handleVideoMessage(message) - case msg.AudioMessage != nil: - b.handleAudioMessage(message) - case msg.DocumentMessage != nil: - b.handleDocumentMessage(message) - case msg.ImageMessage != nil: - b.handleImageMessage(message) - } -} + b.Log.Errorf("Connection failed, underlying error: %v", err) + b.Log.Infof("Waiting %s...", d) -// nolint:funlen -func (b *Bwhatsapp) handleTextMessage(messageInfo types.MessageInfo, msg *proto.Message) { - senderJID := messageInfo.Sender - channel := messageInfo.Chat + time.Sleep(d) - senderName := b.getSenderName(messageInfo.Sender) - if senderName == "" { - senderName = "Someone" // don't expose telephone number + b.Log.Info("Reconnecting...") + + err := b.conn.Restore() + if err == nil { + bf.Reset() + b.startedAt = uint64(time.Now().Unix()) + + return + } } +} - if msg.GetExtendedTextMessage() == nil && msg.GetConversation() == "" { - b.Log.Debugf("message without text content? %#v", msg) +// HandleTextMessage sent from WhatsApp, relay it to the brige +func (b *Bwhatsapp) HandleTextMessage(message whatsapp.TextMessage) { + if message.Info.FromMe { + return + } + // whatsapp sends last messages to show context , cut them + if message.Info.Timestamp < b.startedAt { return } - var text string - - // nolint:nestif - if msg.GetExtendedTextMessage() == nil { - text = msg.GetConversation() - } else { - text = msg.GetExtendedTextMessage().GetText() - ci := msg.GetExtendedTextMessage().GetContextInfo() + groupJID := message.Info.RemoteJid + senderJID := message.Info.SenderJid - if senderJID == (types.JID{}) && ci.Participant != nil { - senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + if len(senderJID) == 0 { + if message.Info.Source != nil && message.Info.Source.Participant != nil { + senderJID = *message.Info.Source.Participant } + } - if ci.MentionedJid != nil { - // handle user mentions - for _, mentionedJID := range ci.MentionedJid { - numberAndSuffix := strings.SplitN(mentionedJID, "@", 2) + // translate sender's JID to the nicest username we can get + senderName := b.getSenderName(senderJID) + if senderName == "" { + senderName = "Someone" // don't expose telephone number + } - // mentions comes as telephone numbers and we don't want to expose it to other bridges - // replace it with something more meaninful to others - mention := b.getSenderNotify(types.NewJID(numberAndSuffix[0], types.DefaultUserServer)) - if mention == "" { - mention = "someone" - } + extText := message.Info.Source.Message.ExtendedTextMessage + if extText != nil && extText.ContextInfo != nil && extText.ContextInfo.MentionedJid != nil { + // handle user mentions + for _, mentionedJID := range extText.ContextInfo.MentionedJid { + numberAndSuffix := strings.SplitN(mentionedJID, "@", 2) - text = strings.Replace(text, "@"+numberAndSuffix[0], "@"+mention, 1) + // mentions comes as telephone numbers and we don't want to expose it to other bridges + // replace it with something more meaninful to others + mention := b.getSenderNotify(numberAndSuffix[0] + "@s.whatsapp.net") + if mention == "" { + mention = "someone" } + + message.Text = strings.Replace(message.Text, "@"+numberAndSuffix[0], "@"+mention, 1) } } rmsg := config.Message{ - UserID: senderJID.String(), + UserID: senderJID, Username: senderName, - Text: text, - Channel: channel.String(), + Text: message.Text, + Channel: groupJID, Account: b.Account, Protocol: b.Protocol, Extra: make(map[string][]interface{}), - // ParentID: TODO, // TODO handle thread replies // map from Info.QuotedMessageID string - ID: messageInfo.ID, + // ParentID: TODO, // TODO handle thread replies // map from Info.QuotedMessageID string + ID: message.Info.Id, } - if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + if avatarURL, exists := b.userAvatars[senderJID]; exists { rmsg.Avatar = avatarURL } @@ -112,32 +134,36 @@ func (b *Bwhatsapp) handleTextMessage(messageInfo types.MessageInfo, msg *proto. } // HandleImageMessage sent from WhatsApp, relay it to the brige -func (b *Bwhatsapp) handleImageMessage(msg *events.Message) { - imsg := msg.Message.GetImageMessage() +func (b *Bwhatsapp) HandleImageMessage(message whatsapp.ImageMessage) { + if message.Info.FromMe || message.Info.Timestamp < b.startedAt { + return + } - senderJID := msg.Info.Sender - senderName := b.getSenderName(senderJID) - ci := imsg.GetContextInfo() + senderJID := message.Info.SenderJid + if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil { + senderJID = *message.Info.Source.Participant + } - if senderJID == (types.JID{}) && ci.Participant != nil { - senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + senderName := b.getSenderName(message.Info.SenderJid) + if senderName == "" { + senderName = "Someone" // don't expose telephone number } rmsg := config.Message{ - UserID: senderJID.String(), + UserID: senderJID, Username: senderName, - Channel: msg.Info.Chat.String(), + Channel: message.Info.RemoteJid, Account: b.Account, Protocol: b.Protocol, Extra: make(map[string][]interface{}), - ID: msg.Info.ID, + ID: message.Info.Id, } - if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + if avatarURL, exists := b.userAvatars[senderJID]; exists { rmsg.Avatar = avatarURL } - fileExt, err := mime.ExtensionsByType(imsg.GetMimetype()) + fileExt, err := mime.ExtensionsByType(message.Type) if err != nil { b.Log.Errorf("Mimetype detection error: %s", err) @@ -154,11 +180,11 @@ func (b *Bwhatsapp) handleImageMessage(msg *events.Message) { fileExt[0] = ".jpg" } - filename := fmt.Sprintf("%v%v", msg.Info.ID, fileExt[0]) + filename := fmt.Sprintf("%v%v", message.Info.Id, fileExt[0]) - b.Log.Debugf("Trying to download %s with type %s", filename, imsg.GetMimetype()) + b.Log.Debugf("Trying to download %s with type %s", filename, message.Type) - data, err := b.wc.Download(imsg) + data, err := message.Download() if err != nil { b.Log.Errorf("Download image failed: %s", err) @@ -166,7 +192,7 @@ func (b *Bwhatsapp) handleImageMessage(msg *events.Message) { } // Move file to bridge storage - helper.HandleDownloadData(b.Log, &rmsg, filename, imsg.GetCaption(), "", &data, b.General) + helper.HandleDownloadData(b.Log, &rmsg, filename, message.Caption, "", &data, b.General) b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) b.Log.Debugf("<= Message is %#v", rmsg) @@ -175,32 +201,36 @@ func (b *Bwhatsapp) handleImageMessage(msg *events.Message) { } // HandleVideoMessage downloads video messages -func (b *Bwhatsapp) handleVideoMessage(msg *events.Message) { - imsg := msg.Message.GetVideoMessage() +func (b *Bwhatsapp) HandleVideoMessage(message whatsapp.VideoMessage) { + if message.Info.FromMe || message.Info.Timestamp < b.startedAt { + return + } - senderJID := msg.Info.Sender - senderName := b.getSenderName(senderJID) - ci := imsg.GetContextInfo() + senderJID := message.Info.SenderJid + if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil { + senderJID = *message.Info.Source.Participant + } - if senderJID == (types.JID{}) && ci.Participant != nil { - senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + senderName := b.getSenderName(message.Info.SenderJid) + if senderName == "" { + senderName = "Someone" // don't expose telephone number } rmsg := config.Message{ - UserID: senderJID.String(), + UserID: senderJID, Username: senderName, - Channel: msg.Info.Chat.String(), + Channel: message.Info.RemoteJid, Account: b.Account, Protocol: b.Protocol, Extra: make(map[string][]interface{}), - ID: msg.Info.ID, + ID: message.Info.Id, } - if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + if avatarURL, exists := b.userAvatars[senderJID]; exists { rmsg.Avatar = avatarURL } - fileExt, err := mime.ExtensionsByType(imsg.GetMimetype()) + fileExt, err := mime.ExtensionsByType(message.Type) if err != nil { b.Log.Errorf("Mimetype detection error: %s", err) @@ -211,11 +241,11 @@ func (b *Bwhatsapp) handleVideoMessage(msg *events.Message) { fileExt = append(fileExt, ".mp4") } - filename := fmt.Sprintf("%v%v", msg.Info.ID, fileExt[0]) + filename := fmt.Sprintf("%v%v", message.Info.Id, fileExt[0]) - b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, imsg.GetFileLength(), imsg.GetMimetype()) + b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, message.Length, message.Type) - data, err := b.wc.Download(imsg) + data, err := message.Download() if err != nil { b.Log.Errorf("Download video failed: %s", err) @@ -223,7 +253,7 @@ func (b *Bwhatsapp) handleVideoMessage(msg *events.Message) { } // Move file to bridge storage - helper.HandleDownloadData(b.Log, &rmsg, filename, imsg.GetCaption(), "", &data, b.General) + helper.HandleDownloadData(b.Log, &rmsg, filename, message.Caption, "", &data, b.General) b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) b.Log.Debugf("<= Message is %#v", rmsg) @@ -232,32 +262,36 @@ func (b *Bwhatsapp) handleVideoMessage(msg *events.Message) { } // HandleAudioMessage downloads audio messages -func (b *Bwhatsapp) handleAudioMessage(msg *events.Message) { - imsg := msg.Message.GetAudioMessage() +func (b *Bwhatsapp) HandleAudioMessage(message whatsapp.AudioMessage) { + if message.Info.FromMe || message.Info.Timestamp < b.startedAt { + return + } - senderJID := msg.Info.Sender - senderName := b.getSenderName(senderJID) - ci := imsg.GetContextInfo() + senderJID := message.Info.SenderJid + if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil { + senderJID = *message.Info.Source.Participant + } - if senderJID == (types.JID{}) && ci.Participant != nil { - senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + senderName := b.getSenderName(message.Info.SenderJid) + if senderName == "" { + senderName = "Someone" // don't expose telephone number } rmsg := config.Message{ - UserID: senderJID.String(), + UserID: senderJID, Username: senderName, - Channel: msg.Info.Chat.String(), + Channel: message.Info.RemoteJid, Account: b.Account, Protocol: b.Protocol, Extra: make(map[string][]interface{}), - ID: msg.Info.ID, + ID: message.Info.Id, } - if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + if avatarURL, exists := b.userAvatars[senderJID]; exists { rmsg.Avatar = avatarURL } - fileExt, err := mime.ExtensionsByType(imsg.GetMimetype()) + fileExt, err := mime.ExtensionsByType(message.Type) if err != nil { b.Log.Errorf("Mimetype detection error: %s", err) @@ -268,13 +302,13 @@ func (b *Bwhatsapp) handleAudioMessage(msg *events.Message) { fileExt = append(fileExt, ".ogg") } - filename := fmt.Sprintf("%v%v", msg.Info.ID, fileExt[0]) + filename := fmt.Sprintf("%v%v", message.Info.Id, fileExt[0]) - b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, imsg.GetFileLength(), imsg.GetMimetype()) + b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, message.Length, message.Type) - data, err := b.wc.Download(imsg) + data, err := message.Download() if err != nil { - b.Log.Errorf("Download video failed: %s", err) + b.Log.Errorf("Download audio failed: %s", err) return } @@ -289,43 +323,47 @@ func (b *Bwhatsapp) handleAudioMessage(msg *events.Message) { } // HandleDocumentMessage downloads documents -func (b *Bwhatsapp) handleDocumentMessage(msg *events.Message) { - imsg := msg.Message.GetDocumentMessage() +func (b *Bwhatsapp) HandleDocumentMessage(message whatsapp.DocumentMessage) { + if message.Info.FromMe || message.Info.Timestamp < b.startedAt { + return + } - senderJID := msg.Info.Sender - senderName := b.getSenderName(senderJID) - ci := imsg.GetContextInfo() + senderJID := message.Info.SenderJid + if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil { + senderJID = *message.Info.Source.Participant + } - if senderJID == (types.JID{}) && ci.Participant != nil { - senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + senderName := b.getSenderName(message.Info.SenderJid) + if senderName == "" { + senderName = "Someone" // don't expose telephone number } rmsg := config.Message{ - UserID: senderJID.String(), + UserID: senderJID, Username: senderName, - Channel: msg.Info.Chat.String(), + Channel: message.Info.RemoteJid, Account: b.Account, Protocol: b.Protocol, Extra: make(map[string][]interface{}), - ID: msg.Info.ID, + ID: message.Info.Id, } - if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + if avatarURL, exists := b.userAvatars[senderJID]; exists { rmsg.Avatar = avatarURL } - fileExt, err := mime.ExtensionsByType(imsg.GetMimetype()) + fileExt, err := mime.ExtensionsByType(message.Type) if err != nil { b.Log.Errorf("Mimetype detection error: %s", err) return } - filename := fmt.Sprintf("%v", imsg.GetFileName()) + filename := fmt.Sprintf("%v", message.FileName) - b.Log.Debugf("Trying to download %s with extension %s and type %s", filename, fileExt, imsg.GetMimetype()) + b.Log.Debugf("Trying to download %s with extension %s and type %s", filename, fileExt, message.Type) - data, err := b.wc.Download(imsg) + data, err := message.Download() if err != nil { b.Log.Errorf("Download document message failed: %s", err) diff --git a/bridge/whatsapp/helpers.go b/bridge/whatsapp/helpers.go index b32372c5..e424387d 100644 --- a/bridge/whatsapp/helpers.go +++ b/bridge/whatsapp/helpers.go @@ -1,12 +1,15 @@ package bwhatsapp import ( + "encoding/gob" + "encoding/json" + "errors" "fmt" + "os" "strings" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/store/sqlstore" - "go.mau.fi/whatsmeow/types" + qrcodeTerminal "github.com/Baozisoftware/qrcode-terminal-go" + "github.com/Rhymen/go-whatsapp" ) type ProfilePicInfo struct { @@ -15,71 +18,141 @@ type ProfilePicInfo struct { Status int16 `json:"status"` } -func (b *Bwhatsapp) getSenderName(senderJid types.JID) string { - if sender, exists := b.contacts[senderJid]; exists { - if sender.FullName != "" { - return sender.FullName - } - // if user is not in phone contacts - // it is the most obvious scenario unless you sync your phone contacts with some remote updated source - // users can change it in their WhatsApp settings -> profile -> click on Avatar - if sender.PushName != "" { - return sender.PushName - } +func qrFromTerminal(invert bool) chan string { + qr := make(chan string) + + go func() { + terminal := qrcodeTerminal.New() - if sender.FirstName != "" { - return sender.FirstName + if invert { + terminal = qrcodeTerminal.New2(qrcodeTerminal.ConsoleColors.BrightWhite, qrcodeTerminal.ConsoleColors.BrightBlack, qrcodeTerminal.QRCodeRecoveryLevels.Medium) } + + terminal.Get(<-qr).Print() + }() + + return qr +} + +func (b *Bwhatsapp) readSession() (whatsapp.Session, error) { + session := whatsapp.Session{} + sessionFile := b.Config.GetString(sessionFile) + + if sessionFile == "" { + return session, errors.New("if you won't set SessionFile then you will need to scan QR code on every restart") } - // try to reload this contact - if _, err := b.wc.Store.Contacts.GetAllContacts(); err != nil { - b.Log.Errorf("error on update of contacts: %v", err) + file, err := os.Open(sessionFile) + if err != nil { + return session, err } - allcontacts, err := b.wc.Store.Contacts.GetAllContacts() + defer file.Close() + + decoder := gob.NewDecoder(file) + + return session, decoder.Decode(&session) +} + +func (b *Bwhatsapp) writeSession(session whatsapp.Session) error { + sessionFile := b.Config.GetString(sessionFile) + + if sessionFile == "" { + // we already sent a warning while starting the bridge, so let's be quiet here + return nil + } + + file, err := os.Create(sessionFile) if err != nil { - b.Log.Errorf("error on update of contacts: %v", err) + return err + } + + defer file.Close() + + encoder := gob.NewEncoder(file) + + return encoder.Encode(session) +} + +func (b *Bwhatsapp) restoreSession() (*whatsapp.Session, error) { + session, err := b.readSession() + if err != nil { + b.Log.Warn(err.Error()) } - if len(allcontacts) > 0 { - b.contacts = allcontacts + b.Log.Debugln("Restoring WhatsApp session..") + + session, err = b.conn.RestoreWithSession(session) + if err != nil { + // restore session connection timed out (I couldn't get over it without logging in again) + return nil, errors.New("failed to restore session: " + err.Error()) } - if sender, exists := b.contacts[senderJid]; exists { - if sender.FullName != "" { - return sender.FullName + b.Log.Debugln("Session restored successfully!") + + return &session, nil +} + +func (b *Bwhatsapp) getSenderName(senderJid string) string { + if sender, exists := b.users[senderJid]; exists { + if sender.Name != "" { + return sender.Name } // if user is not in phone contacts // it is the most obvious scenario unless you sync your phone contacts with some remote updated source // users can change it in their WhatsApp settings -> profile -> click on Avatar - if sender.PushName != "" { - return sender.PushName + if sender.Notify != "" { + return sender.Notify } - if sender.FirstName != "" { - return sender.FirstName + if sender.Short != "" { + return sender.Short } } - return "Someone" + // try to reload this contact + _, err := b.conn.Contacts() + if err != nil { + b.Log.Errorf("error on update of contacts: %v", err) + } + + if contact, exists := b.conn.Store.Contacts[senderJid]; exists { + // Add it to the user map + b.users[senderJid] = contact + + if contact.Name != "" { + return contact.Name + } + // if user is not in phone contacts + // same as above + return contact.Notify + } + + return "" } -func (b *Bwhatsapp) getSenderNotify(senderJid types.JID) string { - if sender, exists := b.contacts[senderJid]; exists { - return sender.PushName +func (b *Bwhatsapp) getSenderNotify(senderJid string) string { + if sender, exists := b.users[senderJid]; exists { + return sender.Notify } return "" } -func (b *Bwhatsapp) GetProfilePicThumb(jid string) (*types.ProfilePictureInfo, error) { - pjid, _ := types.ParseJID(jid) - info, err := b.wc.GetProfilePictureInfo(pjid, true) +func (b *Bwhatsapp) GetProfilePicThumb(jid string) (*ProfilePicInfo, error) { + data, err := b.conn.GetProfilePicThumb(jid) if err != nil { return nil, fmt.Errorf("failed to get avatar: %v", err) } + content := <-data + info := &ProfilePicInfo{} + + err = json.Unmarshal([]byte(content), info) + if err != nil { + return info, fmt.Errorf("failed to unmarshal avatar info: %v", err) + } + return info, nil } @@ -88,19 +161,3 @@ func isGroupJid(identifier string) bool { strings.HasSuffix(identifier, "@temp") || strings.HasSuffix(identifier, "@broadcast") } - -func (b *Bwhatsapp) getDevice() (*store.Device, error) { - device := &store.Device{} - - storeContainer, err := sqlstore.New("sqlite", "file:"+b.Config.GetString("sessionfile")+".db?_foreign_keys=on&_pragma=busy_timeout=10000", nil) - if err != nil { - return device, fmt.Errorf("failed to connect to database: %v", err) - } - - device, err = storeContainer.GetFirstDevice() - if err != nil { - return device, fmt.Errorf("failed to get device: %v", err) - } - - return device, nil -} diff --git a/bridge/whatsapp/whatsapp.go b/bridge/whatsapp/whatsapp.go index 2ba39c14..ba0ede64 100644 --- a/bridge/whatsapp/whatsapp.go +++ b/bridge/whatsapp/whatsapp.go @@ -1,41 +1,38 @@ package bwhatsapp import ( - "context" + "bytes" + "crypto/rand" + "encoding/hex" "errors" "fmt" "mime" "os" "path/filepath" + "strings" "time" "github.com/42wim/matterbridge/bridge" "github.com/42wim/matterbridge/bridge/config" - "github.com/mdp/qrterminal" - - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/binary/proto" - "go.mau.fi/whatsmeow/types" - waLog "go.mau.fi/whatsmeow/util/log" - - goproto "google.golang.org/protobuf/proto" - - _ "modernc.org/sqlite" // needed for sqlite + "github.com/Rhymen/go-whatsapp" ) const ( // Account config parameters - cfgNumber = "Number" + cfgNumber = "Number" + qrOnWhiteTerminal = "QrOnWhiteTerminal" + sessionFile = "SessionFile" ) // Bwhatsapp Bridge structure keeping all the information needed for relying type Bwhatsapp struct { *bridge.Config - startedAt time.Time - wc *whatsmeow.Client - contacts map[types.JID]types.ContactInfo - users map[string]types.ContactInfo + session *whatsapp.Session + conn *whatsapp.Conn + startedAt uint64 + + users map[string]whatsapp.Contact userAvatars map[string]string } @@ -50,7 +47,7 @@ func New(cfg *bridge.Config) bridge.Bridger { b := &Bwhatsapp{ Config: cfg, - users: make(map[string]types.ContactInfo), + users: make(map[string]whatsapp.Contact), userAvatars: make(map[string]string), } @@ -59,92 +56,101 @@ func New(cfg *bridge.Config) bridge.Bridger { // Connect to WhatsApp. Required implementation of the Bridger interface func (b *Bwhatsapp) Connect() error { - device, err := b.getDevice() - if err != nil { - return err - } - number := b.GetString(cfgNumber) if number == "" { return errors.New("whatsapp's telephone number need to be configured") } b.Log.Debugln("Connecting to WhatsApp..") + conn, err := whatsapp.NewConn(20 * time.Second) + if err != nil { + return errors.New("failed to connect to WhatsApp: " + err.Error()) + } - b.wc = whatsmeow.NewClient(device, waLog.Stdout("Client", "INFO", true)) - b.wc.AddEventHandler(b.eventHandler) + b.conn = conn - firstlogin := false - var qrChan <-chan whatsmeow.QRChannelItem - if b.wc.Store.ID == nil { - firstlogin = true - qrChan, err = b.wc.GetQRChannel(context.Background()) - if err != nil && !errors.Is(err, whatsmeow.ErrQRStoreContainsID) { - return errors.New("failed to to get QR channel:" + err.Error()) - } - } + b.conn.AddHandler(b) + b.Log.Debugln("WhatsApp connection successful") - err = b.wc.Connect() + // load existing session in order to keep it between restarts + b.session, err = b.restoreSession() if err != nil { - return errors.New("failed to connect to WhatsApp: " + err.Error()) + b.Log.Warn(err.Error()) } - if b.wc.Store.ID == nil { - for evt := range qrChan { - if evt.Event == "code" { - qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) - } else { - b.Log.Infof("QR channel result: %s", evt.Event) - } + // login to a new session + if b.session == nil { + if err = b.Login(); err != nil { + return err } } - // disconnect and reconnect on our first login/pairing - // for some reason the GetJoinedGroups in JoinChannel doesn't work on first login - if firstlogin { - b.wc.Disconnect() - time.Sleep(time.Second) + b.startedAt = uint64(time.Now().Unix()) - err = b.wc.Connect() - if err != nil { - return errors.New("failed to connect to WhatsApp: " + err.Error()) - } + _, err = b.conn.Contacts() + if err != nil { + return fmt.Errorf("error on update of contacts: %v", err) } - b.Log.Infoln("WhatsApp connection successful") + // see https://github.com/Rhymen/go-whatsapp/issues/137#issuecomment-480316013 + for len(b.conn.Store.Contacts) == 0 { + b.conn.Contacts() // nolint:errcheck - b.contacts, err = b.wc.Store.Contacts.GetAllContacts() - if err != nil { - return errors.New("failed to get contacts: " + err.Error()) + <-time.After(1 * time.Second) } - b.startedAt = time.Now() - // map all the users - for id, contact := range b.contacts { - if !isGroupJid(id.String()) && id.String() != "status@broadcast" { + for id, contact := range b.conn.Store.Contacts { + if !isGroupJid(id) && id != "status@broadcast" { // it is user - b.users[id.String()] = contact + b.users[id] = contact } } // get user avatar asynchronously - b.Log.Info("Getting user avatars..") + go func() { + b.Log.Debug("Getting user avatars..") - for jid := range b.users { - info, err := b.GetProfilePicThumb(jid) - if err != nil { - b.Log.Warnf("Could not get profile photo of %s: %v", jid, err) - } else { - b.Lock() - if info != nil { + for jid := range b.users { + info, err := b.GetProfilePicThumb(jid) + if err != nil { + b.Log.Warnf("Could not get profile photo of %s: %v", jid, err) + } else { + b.Lock() b.userAvatars[jid] = info.URL + b.Unlock() } - b.Unlock() } + + b.Log.Debug("Finished getting avatars..") + }() + + return nil +} + +// Login to WhatsApp creating a new session. This will require to scan a QR code on your mobile device +func (b *Bwhatsapp) Login() error { + b.Log.Debugln("Logging in..") + + invert := b.GetBool(qrOnWhiteTerminal) // false is the default + qrChan := qrFromTerminal(invert) + + session, err := b.conn.Login(qrChan) + if err != nil { + b.Log.Warnln("Failed to log in:", err) + + return err } - b.Log.Info("Finished getting avatars..") + b.session = &session + + b.Log.Infof("Logged into session: %#v", session) + b.Log.Infof("Connection: %#v", b.conn) + + err = b.writeSession(session) + if err != nil { + fmt.Fprintf(os.Stderr, "error saving session: %v\n", err) + } return nil } @@ -152,8 +158,8 @@ func (b *Bwhatsapp) Connect() error { // Disconnect is called while reconnecting to the bridge // Required implementation of the Bridger interface func (b *Bwhatsapp) Disconnect() error { - b.wc.Disconnect() - + // We could Logout, but that would close the session completely and would require a new QR code scan + // https://github.com/Rhymen/go-whatsapp/blob/c31092027237441cffba1b9cb148eadf7c83c3d2/session.go#L377-L381 return nil } @@ -163,118 +169,111 @@ func (b *Bwhatsapp) Disconnect() error { func (b *Bwhatsapp) JoinChannel(channel config.ChannelInfo) error { byJid := isGroupJid(channel.Name) - groups, err := b.wc.GetJoinedGroups() - if err != nil { - return err + // see https://github.com/Rhymen/go-whatsapp/issues/137#issuecomment-480316013 + for len(b.conn.Store.Contacts) == 0 { + b.conn.Contacts() // nolint:errcheck + <-time.After(1 * time.Second) } // verify if we are member of the given group if byJid { - gJID, err := types.ParseJID(channel.Name) - if err != nil { - return err + // channel.Name specifies static group jID, not the name + if _, exists := b.conn.Store.Contacts[channel.Name]; !exists { + return fmt.Errorf("account doesn't belong to group with jid %s", channel.Name) } - for _, group := range groups { - if group.JID == gJID { - return nil - } - } + return nil } - foundGroups := []string{} - - for _, group := range groups { - if group.Name == channel.Name { - foundGroups = append(foundGroups, group.Name) + // channel.Name specifies group name that might change, warn about it + var jids []string + for id, contact := range b.conn.Store.Contacts { + if isGroupJid(id) && contact.Name == channel.Name { + jids = append(jids, id) } } - switch len(foundGroups) { + switch len(jids) { case 0: // didn't match any group - print out possibilites - for _, group := range groups { - b.Log.Infof("%s %s", group.JID, group.Name) + for id, contact := range b.conn.Store.Contacts { + if isGroupJid(id) { + b.Log.Infof("%s %s", contact.Jid, contact.Name) + } } + return fmt.Errorf("please specify group's JID from the list above instead of the name '%s'", channel.Name) case 1: - return fmt.Errorf("group name might change. Please configure gateway with channel=\"%v\" instead of channel=\"%v\"", foundGroups[0], channel.Name) + return fmt.Errorf("group name might change. Please configure gateway with channel=\"%v\" instead of channel=\"%v\"", jids[0], channel.Name) default: - return fmt.Errorf("there is more than one group with name '%s'. Please specify one of JIDs as channel name: %v", channel.Name, foundGroups) + return fmt.Errorf("there is more than one group with name '%s'. Please specify one of JIDs as channel name: %v", channel.Name, jids) } } // Post a document message from the bridge to WhatsApp func (b *Bwhatsapp) PostDocumentMessage(msg config.Message, filetype string) (string, error) { - groupJID, _ := types.ParseJID(msg.Channel) - fi := msg.Extra["file"][0].(config.FileInfo) - resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaDocument) - if err != nil { - return "", err - } - // Post document message - var message proto.Message - - message.DocumentMessage = &proto.DocumentMessage{ - Title: &fi.Name, - FileName: &fi.Name, - Mimetype: &filetype, - MediaKey: resp.MediaKey, - FileEncSha256: resp.FileEncSHA256, - FileSha256: resp.FileSHA256, - FileLength: goproto.Uint64(resp.FileLength), - Url: &resp.URL, + message := whatsapp.DocumentMessage{ + Info: whatsapp.MessageInfo{ + RemoteJid: msg.Channel, + }, + Title: fi.Name, + FileName: fi.Name, + Type: filetype, + Content: bytes.NewReader(*fi.Data), } b.Log.Debugf("=> Sending %#v", msg) - ID := whatsmeow.GenerateMessageID() - _, err = b.wc.SendMessage(groupJID, ID, &message) + // create message ID + // TODO follow and act if https://github.com/Rhymen/go-whatsapp/issues/101 implemented + idBytes := make([]byte, 10) + if _, err := rand.Read(idBytes); err != nil { + b.Log.Warn(err.Error()) + } + + message.Info.Id = strings.ToUpper(hex.EncodeToString(idBytes)) + _, err := b.conn.Send(message) - return ID, err + return message.Info.Id, err } // Post an image message from the bridge to WhatsApp // Handle, for sure image/jpeg, image/png and image/gif MIME types func (b *Bwhatsapp) PostImageMessage(msg config.Message, filetype string) (string, error) { - groupJID, _ := types.ParseJID(msg.Channel) - fi := msg.Extra["file"][0].(config.FileInfo) - caption := msg.Username + fi.Comment - - resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaImage) - if err != nil { - return "", err + // Post image message + message := whatsapp.ImageMessage{ + Info: whatsapp.MessageInfo{ + RemoteJid: msg.Channel, + }, + Type: filetype, + Caption: msg.Username + fi.Comment, + Content: bytes.NewReader(*fi.Data), } - var message proto.Message + b.Log.Debugf("=> Sending %#v", msg) - message.ImageMessage = &proto.ImageMessage{ - Mimetype: &filetype, - Caption: &caption, - MediaKey: resp.MediaKey, - FileEncSha256: resp.FileEncSHA256, - FileSha256: resp.FileSHA256, - FileLength: goproto.Uint64(resp.FileLength), - Url: &resp.URL, + // create message ID + // TODO follow and act if https://github.com/Rhymen/go-whatsapp/issues/101 implemented + idBytes := make([]byte, 10) + if _, err := rand.Read(idBytes); err != nil { + b.Log.Warn(err.Error()) } - b.Log.Debugf("=> Sending %#v", msg) - - ID := whatsmeow.GenerateMessageID() - _, err = b.wc.SendMessage(groupJID, ID, &message) + message.Info.Id = strings.ToUpper(hex.EncodeToString(idBytes)) + _, err := b.conn.Send(message) - return ID, err + return message.Info.Id, err } // Send a message from the bridge to WhatsApp +// Required implementation of the Bridger interface +// https://github.com/42wim/matterbridge/blob/2cfd880cdb0df29771bf8f31df8d990ab897889d/bridge/bridge.go#L11-L16 func (b *Bwhatsapp) Send(msg config.Message) (string, error) { - groupJID, _ := types.ParseJID(msg.Channel) - b.Log.Debugf("=> Receiving %#v", msg) // Delete message @@ -285,7 +284,7 @@ func (b *Bwhatsapp) Send(msg config.Message) (string, error) { return "", nil } - _, err := b.wc.RevokeMessage(groupJID, msg.ID) + _, err := b.conn.RevokeMessage(msg.Channel, msg.ID, true) return "", err } @@ -318,14 +317,20 @@ func (b *Bwhatsapp) Send(msg config.Message) (string, error) { } } - text := msg.Username + msg.Text - - var message proto.Message - - message.Conversation = &text + // Post text message + message := whatsapp.TextMessage{ + Info: whatsapp.MessageInfo{ + RemoteJid: msg.Channel, // which equals to group id + }, + Text: msg.Username + msg.Text, + } - ID := whatsmeow.GenerateMessageID() - _, err := b.wc.SendMessage(groupJID, ID, &message) + b.Log.Debugf("=> Sending %#v", msg) - return ID, err + return b.conn.Send(message) } + +// TODO do we want that? to allow login with QR code from a bridged channel? https://github.com/tulir/mautrix-whatsapp/blob/513eb18e2d59bada0dd515ee1abaaf38a3bfe3d5/commands.go#L76 +//func (b *Bwhatsapp) Command(cmd string) string { +// return "" +//} diff --git a/bridge/whatsappmulti/handlers.go b/bridge/whatsappmulti/handlers.go new file mode 100644 index 00000000..c6b96a5e --- /dev/null +++ b/bridge/whatsappmulti/handlers.go @@ -0,0 +1,344 @@ +// +build whatsappmulti + +package bwhatsapp + +import ( + "fmt" + "mime" + "strings" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + + "go.mau.fi/whatsmeow/binary/proto" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +// nolint:gocritic +func (b *Bwhatsapp) eventHandler(evt interface{}) { + switch e := evt.(type) { + case *events.Message: + b.handleMessage(e) + } +} + +func (b *Bwhatsapp) handleMessage(message *events.Message) { + msg := message.Message + switch { + case msg == nil, message.Info.IsFromMe, message.Info.Timestamp.Before(b.startedAt): + return + } + + b.Log.Infof("Receiving message %#v", msg) + + switch { + case msg.Conversation != nil || msg.ExtendedTextMessage != nil: + b.handleTextMessage(message.Info, msg) + case msg.VideoMessage != nil: + b.handleVideoMessage(message) + case msg.AudioMessage != nil: + b.handleAudioMessage(message) + case msg.DocumentMessage != nil: + b.handleDocumentMessage(message) + case msg.ImageMessage != nil: + b.handleImageMessage(message) + } +} + +// nolint:funlen +func (b *Bwhatsapp) handleTextMessage(messageInfo types.MessageInfo, msg *proto.Message) { + senderJID := messageInfo.Sender + channel := messageInfo.Chat + + senderName := b.getSenderName(messageInfo.Sender) + if senderName == "" { + senderName = "Someone" // don't expose telephone number + } + + if msg.GetExtendedTextMessage() == nil && msg.GetConversation() == "" { + b.Log.Debugf("message without text content? %#v", msg) + return + } + + var text string + + // nolint:nestif + if msg.GetExtendedTextMessage() == nil { + text = msg.GetConversation() + } else { + text = msg.GetExtendedTextMessage().GetText() + ci := msg.GetExtendedTextMessage().GetContextInfo() + + if senderJID == (types.JID{}) && ci.Participant != nil { + senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + } + + if ci.MentionedJid != nil { + // handle user mentions + for _, mentionedJID := range ci.MentionedJid { + numberAndSuffix := strings.SplitN(mentionedJID, "@", 2) + + // mentions comes as telephone numbers and we don't want to expose it to other bridges + // replace it with something more meaninful to others + mention := b.getSenderNotify(types.NewJID(numberAndSuffix[0], types.DefaultUserServer)) + if mention == "" { + mention = "someone" + } + + text = strings.Replace(text, "@"+numberAndSuffix[0], "@"+mention, 1) + } + } + } + + rmsg := config.Message{ + UserID: senderJID.String(), + Username: senderName, + Text: text, + Channel: channel.String(), + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + // ParentID: TODO, // TODO handle thread replies // map from Info.QuotedMessageID string + ID: messageInfo.ID, + } + + if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + rmsg.Avatar = avatarURL + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} + +// HandleImageMessage sent from WhatsApp, relay it to the brige +func (b *Bwhatsapp) handleImageMessage(msg *events.Message) { + imsg := msg.Message.GetImageMessage() + + senderJID := msg.Info.Sender + senderName := b.getSenderName(senderJID) + ci := imsg.GetContextInfo() + + if senderJID == (types.JID{}) && ci.Participant != nil { + senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + } + + rmsg := config.Message{ + UserID: senderJID.String(), + Username: senderName, + Channel: msg.Info.Chat.String(), + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + ID: msg.Info.ID, + } + + if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + rmsg.Avatar = avatarURL + } + + fileExt, err := mime.ExtensionsByType(imsg.GetMimetype()) + if err != nil { + b.Log.Errorf("Mimetype detection error: %s", err) + + return + } + + // rename .jfif to .jpg https://github.com/42wim/matterbridge/issues/1292 + if fileExt[0] == ".jfif" { + fileExt[0] = ".jpg" + } + + // rename .jpe to .jpg https://github.com/42wim/matterbridge/issues/1463 + if fileExt[0] == ".jpe" { + fileExt[0] = ".jpg" + } + + filename := fmt.Sprintf("%v%v", msg.Info.ID, fileExt[0]) + + b.Log.Debugf("Trying to download %s with type %s", filename, imsg.GetMimetype()) + + data, err := b.wc.Download(imsg) + if err != nil { + b.Log.Errorf("Download image failed: %s", err) + + return + } + + // Move file to bridge storage + helper.HandleDownloadData(b.Log, &rmsg, filename, imsg.GetCaption(), "", &data, b.General) + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} + +// HandleVideoMessage downloads video messages +func (b *Bwhatsapp) handleVideoMessage(msg *events.Message) { + imsg := msg.Message.GetVideoMessage() + + senderJID := msg.Info.Sender + senderName := b.getSenderName(senderJID) + ci := imsg.GetContextInfo() + + if senderJID == (types.JID{}) && ci.Participant != nil { + senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + } + + rmsg := config.Message{ + UserID: senderJID.String(), + Username: senderName, + Channel: msg.Info.Chat.String(), + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + ID: msg.Info.ID, + } + + if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + rmsg.Avatar = avatarURL + } + + fileExt, err := mime.ExtensionsByType(imsg.GetMimetype()) + if err != nil { + b.Log.Errorf("Mimetype detection error: %s", err) + + return + } + + if len(fileExt) == 0 { + fileExt = append(fileExt, ".mp4") + } + + filename := fmt.Sprintf("%v%v", msg.Info.ID, fileExt[0]) + + b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, imsg.GetFileLength(), imsg.GetMimetype()) + + data, err := b.wc.Download(imsg) + if err != nil { + b.Log.Errorf("Download video failed: %s", err) + + return + } + + // Move file to bridge storage + helper.HandleDownloadData(b.Log, &rmsg, filename, imsg.GetCaption(), "", &data, b.General) + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} + +// HandleAudioMessage downloads audio messages +func (b *Bwhatsapp) handleAudioMessage(msg *events.Message) { + imsg := msg.Message.GetAudioMessage() + + senderJID := msg.Info.Sender + senderName := b.getSenderName(senderJID) + ci := imsg.GetContextInfo() + + if senderJID == (types.JID{}) && ci.Participant != nil { + senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + } + + rmsg := config.Message{ + UserID: senderJID.String(), + Username: senderName, + Channel: msg.Info.Chat.String(), + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + ID: msg.Info.ID, + } + + if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + rmsg.Avatar = avatarURL + } + + fileExt, err := mime.ExtensionsByType(imsg.GetMimetype()) + if err != nil { + b.Log.Errorf("Mimetype detection error: %s", err) + + return + } + + if len(fileExt) == 0 { + fileExt = append(fileExt, ".ogg") + } + + filename := fmt.Sprintf("%v%v", msg.Info.ID, fileExt[0]) + + b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, imsg.GetFileLength(), imsg.GetMimetype()) + + data, err := b.wc.Download(imsg) + if err != nil { + b.Log.Errorf("Download video failed: %s", err) + + return + } + + // Move file to bridge storage + helper.HandleDownloadData(b.Log, &rmsg, filename, "audio message", "", &data, b.General) + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} + +// HandleDocumentMessage downloads documents +func (b *Bwhatsapp) handleDocumentMessage(msg *events.Message) { + imsg := msg.Message.GetDocumentMessage() + + senderJID := msg.Info.Sender + senderName := b.getSenderName(senderJID) + ci := imsg.GetContextInfo() + + if senderJID == (types.JID{}) && ci.Participant != nil { + senderJID = types.NewJID(ci.GetParticipant(), types.DefaultUserServer) + } + + rmsg := config.Message{ + UserID: senderJID.String(), + Username: senderName, + Channel: msg.Info.Chat.String(), + Account: b.Account, + Protocol: b.Protocol, + Extra: make(map[string][]interface{}), + ID: msg.Info.ID, + } + + if avatarURL, exists := b.userAvatars[senderJID.String()]; exists { + rmsg.Avatar = avatarURL + } + + fileExt, err := mime.ExtensionsByType(imsg.GetMimetype()) + if err != nil { + b.Log.Errorf("Mimetype detection error: %s", err) + + return + } + + filename := fmt.Sprintf("%v", imsg.GetFileName()) + + b.Log.Debugf("Trying to download %s with extension %s and type %s", filename, fileExt, imsg.GetMimetype()) + + data, err := b.wc.Download(imsg) + if err != nil { + b.Log.Errorf("Download document message failed: %s", err) + + return + } + + // Move file to bridge storage + helper.HandleDownloadData(b.Log, &rmsg, filename, "document", "", &data, b.General) + + b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + + b.Remote <- rmsg +} diff --git a/bridge/whatsappmulti/helpers.go b/bridge/whatsappmulti/helpers.go new file mode 100644 index 00000000..a7cc5c98 --- /dev/null +++ b/bridge/whatsappmulti/helpers.go @@ -0,0 +1,108 @@ +// +build whatsappmulti + +package bwhatsapp + +import ( + "fmt" + "strings" + + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" +) + +type ProfilePicInfo struct { + URL string `json:"eurl"` + Tag string `json:"tag"` + Status int16 `json:"status"` +} + +func (b *Bwhatsapp) getSenderName(senderJid types.JID) string { + if sender, exists := b.contacts[senderJid]; exists { + if sender.FullName != "" { + return sender.FullName + } + // if user is not in phone contacts + // it is the most obvious scenario unless you sync your phone contacts with some remote updated source + // users can change it in their WhatsApp settings -> profile -> click on Avatar + if sender.PushName != "" { + return sender.PushName + } + + if sender.FirstName != "" { + return sender.FirstName + } + } + + // try to reload this contact + if _, err := b.wc.Store.Contacts.GetAllContacts(); err != nil { + b.Log.Errorf("error on update of contacts: %v", err) + } + + allcontacts, err := b.wc.Store.Contacts.GetAllContacts() + if err != nil { + b.Log.Errorf("error on update of contacts: %v", err) + } + + if len(allcontacts) > 0 { + b.contacts = allcontacts + } + + if sender, exists := b.contacts[senderJid]; exists { + if sender.FullName != "" { + return sender.FullName + } + // if user is not in phone contacts + // it is the most obvious scenario unless you sync your phone contacts with some remote updated source + // users can change it in their WhatsApp settings -> profile -> click on Avatar + if sender.PushName != "" { + return sender.PushName + } + + if sender.FirstName != "" { + return sender.FirstName + } + } + + return "Someone" +} + +func (b *Bwhatsapp) getSenderNotify(senderJid types.JID) string { + if sender, exists := b.contacts[senderJid]; exists { + return sender.PushName + } + + return "" +} + +func (b *Bwhatsapp) GetProfilePicThumb(jid string) (*types.ProfilePictureInfo, error) { + pjid, _ := types.ParseJID(jid) + info, err := b.wc.GetProfilePictureInfo(pjid, true) + if err != nil { + return nil, fmt.Errorf("failed to get avatar: %v", err) + } + + return info, nil +} + +func isGroupJid(identifier string) bool { + return strings.HasSuffix(identifier, "@g.us") || + strings.HasSuffix(identifier, "@temp") || + strings.HasSuffix(identifier, "@broadcast") +} + +func (b *Bwhatsapp) getDevice() (*store.Device, error) { + device := &store.Device{} + + storeContainer, err := sqlstore.New("sqlite", "file:"+b.Config.GetString("sessionfile")+".db?_foreign_keys=on&_pragma=busy_timeout=10000", nil) + if err != nil { + return device, fmt.Errorf("failed to connect to database: %v", err) + } + + device, err = storeContainer.GetFirstDevice() + if err != nil { + return device, fmt.Errorf("failed to get device: %v", err) + } + + return device, nil +} diff --git a/bridge/whatsappmulti/whatsapp.go b/bridge/whatsappmulti/whatsapp.go new file mode 100644 index 00000000..6b514451 --- /dev/null +++ b/bridge/whatsappmulti/whatsapp.go @@ -0,0 +1,333 @@ +// +build whatsappmulti + +package bwhatsapp + +import ( + "context" + "errors" + "fmt" + "mime" + "os" + "path/filepath" + "time" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/mdp/qrterminal" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/binary/proto" + "go.mau.fi/whatsmeow/types" + waLog "go.mau.fi/whatsmeow/util/log" + + goproto "google.golang.org/protobuf/proto" + + _ "modernc.org/sqlite" // needed for sqlite +) + +const ( + // Account config parameters + cfgNumber = "Number" +) + +// Bwhatsapp Bridge structure keeping all the information needed for relying +type Bwhatsapp struct { + *bridge.Config + + startedAt time.Time + wc *whatsmeow.Client + contacts map[types.JID]types.ContactInfo + users map[string]types.ContactInfo + userAvatars map[string]string +} + +// New Create a new WhatsApp bridge. This will be called for each [whatsapp.<server>] entry you have in the config file +func New(cfg *bridge.Config) bridge.Bridger { + number := cfg.GetString(cfgNumber) + + if number == "" { + cfg.Log.Fatalf("Missing configuration for WhatsApp bridge: Number") + } + + b := &Bwhatsapp{ + Config: cfg, + + users: make(map[string]types.ContactInfo), + userAvatars: make(map[string]string), + } + + return b +} + +// Connect to WhatsApp. Required implementation of the Bridger interface +func (b *Bwhatsapp) Connect() error { + device, err := b.getDevice() + if err != nil { + return err + } + + number := b.GetString(cfgNumber) + if number == "" { + return errors.New("whatsapp's telephone number need to be configured") + } + + b.Log.Debugln("Connecting to WhatsApp..") + + b.wc = whatsmeow.NewClient(device, waLog.Stdout("Client", "INFO", true)) + b.wc.AddEventHandler(b.eventHandler) + + firstlogin := false + var qrChan <-chan whatsmeow.QRChannelItem + if b.wc.Store.ID == nil { + firstlogin = true + qrChan, err = b.wc.GetQRChannel(context.Background()) + if err != nil && !errors.Is(err, whatsmeow.ErrQRStoreContainsID) { + return errors.New("failed to to get QR channel:" + err.Error()) + } + } + + err = b.wc.Connect() + if err != nil { + return errors.New("failed to connect to WhatsApp: " + err.Error()) + } + + if b.wc.Store.ID == nil { + for evt := range qrChan { + if evt.Event == "code" { + qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) + } else { + b.Log.Infof("QR channel result: %s", evt.Event) + } + } + } + + // disconnect and reconnect on our first login/pairing + // for some reason the GetJoinedGroups in JoinChannel doesn't work on first login + if firstlogin { + b.wc.Disconnect() + time.Sleep(time.Second) + + err = b.wc.Connect() + if err != nil { + return errors.New("failed to connect to WhatsApp: " + err.Error()) + } + } + + b.Log.Infoln("WhatsApp connection successful") + + b.contacts, err = b.wc.Store.Contacts.GetAllContacts() + if err != nil { + return errors.New("failed to get contacts: " + err.Error()) + } + + b.startedAt = time.Now() + + // map all the users + for id, contact := range b.contacts { + if !isGroupJid(id.String()) && id.String() != "status@broadcast" { + // it is user + b.users[id.String()] = contact + } + } + + // get user avatar asynchronously + b.Log.Info("Getting user avatars..") + + for jid := range b.users { + info, err := b.GetProfilePicThumb(jid) + if err != nil { + b.Log.Warnf("Could not get profile photo of %s: %v", jid, err) + } else { + b.Lock() + if info != nil { + b.userAvatars[jid] = info.URL + } + b.Unlock() + } + } + + b.Log.Info("Finished getting avatars..") + + return nil +} + +// Disconnect is called while reconnecting to the bridge +// Required implementation of the Bridger interface +func (b *Bwhatsapp) Disconnect() error { + b.wc.Disconnect() + + return nil +} + +// JoinChannel Join a WhatsApp group specified in gateway config as channel='number-id@g.us' or channel='Channel name' +// Required implementation of the Bridger interface +// https://github.com/42wim/matterbridge/blob/2cfd880cdb0df29771bf8f31df8d990ab897889d/bridge/bridge.go#L11-L16 +func (b *Bwhatsapp) JoinChannel(channel config.ChannelInfo) error { + byJid := isGroupJid(channel.Name) + + groups, err := b.wc.GetJoinedGroups() + if err != nil { + return err + } + + // verify if we are member of the given group + if byJid { + gJID, err := types.ParseJID(channel.Name) + if err != nil { + return err + } + + for _, group := range groups { + if group.JID == gJID { + return nil + } + } + } + + foundGroups := []string{} + + for _, group := range groups { + if group.Name == channel.Name { + foundGroups = append(foundGroups, group.Name) + } + } + + switch len(foundGroups) { + case 0: + // didn't match any group - print out possibilites + for _, group := range groups { + b.Log.Infof("%s %s", group.JID, group.Name) + } + return fmt.Errorf("please specify group's JID from the list above instead of the name '%s'", channel.Name) + case 1: + return fmt.Errorf("group name might change. Please configure gateway with channel=\"%v\" instead of channel=\"%v\"", foundGroups[0], channel.Name) + default: + return fmt.Errorf("there is more than one group with name '%s'. Please specify one of JIDs as channel name: %v", channel.Name, foundGroups) + } +} + +// Post a document message from the bridge to WhatsApp +func (b *Bwhatsapp) PostDocumentMessage(msg config.Message, filetype string) (string, error) { + groupJID, _ := types.ParseJID(msg.Channel) + + fi := msg.Extra["file"][0].(config.FileInfo) + + resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaDocument) + if err != nil { + return "", err + } + + // Post document message + var message proto.Message + + message.DocumentMessage = &proto.DocumentMessage{ + Title: &fi.Name, + FileName: &fi.Name, + Mimetype: &filetype, + MediaKey: resp.MediaKey, + FileEncSha256: resp.FileEncSHA256, + FileSha256: resp.FileSHA256, + FileLength: goproto.Uint64(resp.FileLength), + Url: &resp.URL, + } + + b.Log.Debugf("=> Sending %#v", msg) + + ID := whatsmeow.GenerateMessageID() + _, err = b.wc.SendMessage(groupJID, ID, &message) + + return ID, err +} + +// Post an image message from the bridge to WhatsApp +// Handle, for sure image/jpeg, image/png and image/gif MIME types +func (b *Bwhatsapp) PostImageMessage(msg config.Message, filetype string) (string, error) { + groupJID, _ := types.ParseJID(msg.Channel) + + fi := msg.Extra["file"][0].(config.FileInfo) + + caption := msg.Username + fi.Comment + + resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaImage) + if err != nil { + return "", err + } + + var message proto.Message + + message.ImageMessage = &proto.ImageMessage{ + Mimetype: &filetype, + Caption: &caption, + MediaKey: resp.MediaKey, + FileEncSha256: resp.FileEncSHA256, + FileSha256: resp.FileSHA256, + FileLength: goproto.Uint64(resp.FileLength), + Url: &resp.URL, + } + + b.Log.Debugf("=> Sending %#v", msg) + + ID := whatsmeow.GenerateMessageID() + _, err = b.wc.SendMessage(groupJID, ID, &message) + + return ID, err +} + +// Send a message from the bridge to WhatsApp +func (b *Bwhatsapp) Send(msg config.Message) (string, error) { + groupJID, _ := types.ParseJID(msg.Channel) + + b.Log.Debugf("=> Receiving %#v", msg) + + // Delete message + if msg.Event == config.EventMsgDelete { + if msg.ID == "" { + // No message ID in case action is executed on a message sent before the bridge was started + // and then the bridge cache doesn't have this message ID mapped + return "", nil + } + + _, err := b.wc.RevokeMessage(groupJID, msg.ID) + + return "", err + } + + // Edit message + if msg.ID != "" { + b.Log.Debugf("updating message with id %s", msg.ID) + + if b.GetString("editsuffix") != "" { + msg.Text += b.GetString("EditSuffix") + } else { + msg.Text += " (edited)" + } + } + + // Handle Upload a file + if msg.Extra["file"] != nil { + fi := msg.Extra["file"][0].(config.FileInfo) + filetype := mime.TypeByExtension(filepath.Ext(fi.Name)) + + b.Log.Debugf("Extra file is %#v", filetype) + + // TODO: add different types + // TODO: add webp conversion + switch filetype { + case "image/jpeg", "image/png", "image/gif": + return b.PostImageMessage(msg, filetype) + default: + return b.PostDocumentMessage(msg, filetype) + } + } + + text := msg.Username + msg.Text + + var message proto.Message + + message.Conversation = &text + + ID := whatsmeow.GenerateMessageID() + _, err := b.wc.SendMessage(groupJID, ID, &message) + + return ID, err +} |