Thumbnail

rani/matterbridge.git

Clone URL: https://git.buni.party/rani/matterbridge.git

commit 901d3e9776b44339e867efadc2fea024afa675bc Author: Wim <wim@42.be> Date: Sun Mar 20 02:20:54 2022 +0000 Add whatsappmulti buildflag for whatsapp with multidevice support (whatsapp) diff --git a/bridge/whatsapp/handlers.go b/bridge/whatsapp/handlers.go index bac886d..1be9b5e 100644 --- a/bridge/whatsapp/handlers.go +++ b/bridge/whatsapp/handlers.go @@ -4104 +4126 @@ 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   }   @@ -11232 +13436 @@ 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)   @@ -15411 +18011 @@ 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)   @@ -1667 +1927 @@ 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) @@ -17532 +20136 @@ 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)   @@ -21111 +24111 @@ 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)   @@ -2237 +2537 @@ 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) @@ -23232 +26236 @@ 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)   @@ -26813 +30213 @@ 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   } @@ -28943 +32347 @@ 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 b32372c..e424387 100644 --- a/bridge/whatsapp/helpers.go +++ b/bridge/whatsapp/helpers.go @@ -112 +115 @@  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 { @@ -1571 +18141 @@ 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  }   @@ -8819 +1613 @@ 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 2ba39c1..ba0ede6 100644 --- a/bridge/whatsapp/whatsapp.go +++ b/bridge/whatsapp/whatsapp.go @@ -141 +138 @@  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  }   @@ -507 +477 @@ 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),   }   @@ -5992 +56101 @@ 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  } @@ -1528 +1588 @@ 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  }   @@ -163118 +169111 @@ 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 @@ -2857 +2847 @@ 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   } @@ -31814 +31720 @@ 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 0000000..c6b96a5 --- /dev/null +++ b/bridge/whatsappmulti/handlers.go @@ -00 +1344 @@ +// +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 0000000..a7cc5c9 --- /dev/null +++ b/bridge/whatsappmulti/helpers.go @@ -00 +1108 @@ +// +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 0000000..6b51445 --- /dev/null +++ b/bridge/whatsappmulti/whatsapp.go @@ -00 +1333 @@ +// +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 +} diff --git a/gateway/bridgemap/bwhatsapp.go b/gateway/bridgemap/bwhatsapp.go index ef6d6f7..e7b72b0 100644 --- a/gateway/bridgemap/bwhatsapp.go +++ b/gateway/bridgemap/bwhatsapp.go @@ -14 +15 @@  // +build !nowhatsapp +// +build !whatsappmulti    package bridgemap   diff --git a/gateway/bridgemap/bwhatsappmulti.go b/gateway/bridgemap/bwhatsappmulti.go new file mode 100644 index 0000000..055c6da --- /dev/null +++ b/gateway/bridgemap/bwhatsappmulti.go @@ -00 +111 @@ +// +build whatsappmulti + +package bridgemap + +import ( + bwhatsapp "github.com/42wim/matterbridge/bridge/whatsappmulti" +) + +func init() { + FullMap["whatsapp"] = bwhatsapp.New +} diff --git a/go.mod b/go.mod index 53f9a53..edb70a5 100644 --- a/go.mod +++ b/go.mod @@ -28 +210 @@ module github.com/42wim/matterbridge    require (   github.com/42wim/go-gitter v0.0.0-20170828205020-017310c2d557 + github.com/Baozisoftware/qrcode-terminal-go v0.0.0-20170407111555-c0650d8dff0f   github.com/Benau/tgsconverter v0.0.0-20210809170556-99f4a4f6337f   github.com/Philipp15b/go-steam v1.0.1-0.20200727090957-6ae9b3c0a560 + github.com/Rhymen/go-whatsapp v0.1.2-0.20211102134409-31a2e740845c   github.com/SevereCloud/vksdk/v2 v2.13.1   github.com/bwmarrin/discordgo v0.24.0   github.com/d5/tengo/v2 v2.10.1 @@ -1106 +1127 @@ require (   github.com/rivo/uniseg v0.2.0 // indirect   github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4 // indirect   github.com/sizeofint/webpanimation v0.0.0-20210809145948-1d2b32119882 // indirect + github.com/skip2/go-qrcode v0.0.0-20190110000554-dc11ecdae0a9 // indirect   github.com/spf13/afero v1.6.0 // indirect   github.com/spf13/cast v1.4.1 // indirect   github.com/spf13/jwalterweatherman v1.1.0 // indirect diff --git a/go.sum b/go.sum index 5368c48..5bd0300 100644 --- a/go.sum +++ b/go.sum @@ -906 +908 @@ github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935  github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=  github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=  github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Baozisoftware/qrcode-terminal-go v0.0.0-20170407111555-c0650d8dff0f h1:2dk3eOnYllh+wUOuDhOoC2vUVoJF/5z478ryJ+wzEII= +github.com/Baozisoftware/qrcode-terminal-go v0.0.0-20170407111555-c0650d8dff0f/go.mod h1:4a58ifQTEe2uwwsaqbh3i2un5/CBPg+At/qHpt18Tmk=  github.com/Benau/go_rlottie v0.0.0-20210807002906-98c1b2421989 h1:+wrfJITuBoQOE6ST4k3c4EortNVQXVhfAbwt0M/j0+Y=  github.com/Benau/go_rlottie v0.0.0-20210807002906-98c1b2421989/go.mod h1:aDWSWjsayFyGTvHZH3v4ijGXEBe51xcEkAK+NUWeOeo=  github.com/Benau/tgsconverter v0.0.0-20210809170556-99f4a4f6337f h1:aUkwZDEMJIGRcWlSDifSLoKG37UCOH/DPeG52/xwois= @@ -1446 +1468 @@ github.com/PuerkitoBio/goquery v1.7.0/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBK  github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=  github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=  github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/Rhymen/go-whatsapp v0.1.2-0.20211102134409-31a2e740845c h1:4mIZQXKYBymQ9coA82nNyG/CjicMNLBZ8cPVrhNUM3g= +github.com/Rhymen/go-whatsapp v0.1.2-0.20211102134409-31a2e740845c/go.mod h1:DNSFRLFDFIqm2+0aJzSOVfn25020vldM4SRqz6YtLgI=  github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=  github.com/RoaringBitmap/roaring v0.8.0/go.mod h1:jdT9ykXwHFNdJbEtxePexlFYH9LXucApeS0/+/g+p1I=  github.com/RoaringBitmap/roaring v0.9.4/go.mod h1:icnadbWcNyfEHlYdr+tDlOTih1Bf/h+rzPpv4sbomAA= @@ -14976 +15018 @@ github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE  github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=  github.com/sizeofint/webpanimation v0.0.0-20210809145948-1d2b32119882 h1:A7o8tOERTtpD/poS+2VoassCjXpjHn916luXbf5QKD0=  github.com/sizeofint/webpanimation v0.0.0-20210809145948-1d2b32119882/go.mod h1:5IwJoz9Pw7JsrCN4/skkxUtSWT7myuUPLhCgv6Q5vvQ= +github.com/skip2/go-qrcode v0.0.0-20190110000554-dc11ecdae0a9 h1:lpEzuenPuO1XNTeikEmvqYFcU37GVLl8SRNblzyvGBE= +github.com/skip2/go-qrcode v0.0.0-20190110000554-dc11ecdae0a9/go.mod h1:PLPIyL7ikehBD1OAjmKKiOEhbvWyHGaNDjquXMcYABo=  github.com/slack-go/slack v0.10.2 h1:KMN/h2sgUninHXvQI8PrR/PHBUuWp2NPvz2Kr66tki4=  github.com/slack-go/slack v0.10.2/go.mod h1:5FLdBRv7VW/d9EBxx/eEktOptWygbA9K2QK/KW7ds1s=  github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=