mirror of
https://github.com/cwinfo/matterbridge.git
synced 2024-11-28 13:41:37 +00:00
d00dcf3f58
* Handle Whatsapp threading/replies. In this change we are updating whatsapp message IDs to include sender JID as this is necessary to reply to a message https://github.com/tulir/whatsmeow/issues/88#issuecomment-1093195237 https://github.com/tulir/whatsmeow/discussions/148#discussioncomment-3094325 Based on commit 6afa93e537c53b371db37f35a3546ff0fb669416 from #1934 Author: Iiro Laiho <iiro.laiho@iki.fi> * Fix replies. Sender JID can have a `:` inside of it, using `/` as a delimiter now. Added messageID parser + struct. messages sent with an attachment do not show replies But at least common `sendMessage` will make repies from whatsapp to an attachement bridge across. The new message ID format broke message deleting, so we change the messageID into the real id at the beginning of send. We really do need the extra info for when we reply to a message though. * Refactored message replies. file/Image/audio/replies all work now.
452 lines
12 KiB
Go
452 lines
12 KiB
Go
//go:build whatsappmulti
|
|
// +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
|
|
}
|
|
|
|
type Replyable struct {
|
|
MessageID types.MessageID
|
|
Sender types.JID
|
|
}
|
|
|
|
// 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)
|
|
|
|
caption := msg.Username + fi.Comment
|
|
|
|
resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaDocument)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Post document message
|
|
var message proto.Message
|
|
var ctx *proto.ContextInfo
|
|
if msg.ParentID != "" {
|
|
ctx, _ = b.getNewReplyContext(msg.ParentID)
|
|
}
|
|
|
|
message.DocumentMessage = &proto.DocumentMessage{
|
|
Title: &fi.Name,
|
|
FileName: &fi.Name,
|
|
Mimetype: &filetype,
|
|
Caption: &caption,
|
|
MediaKey: resp.MediaKey,
|
|
FileEncSha256: resp.FileEncSHA256,
|
|
FileSha256: resp.FileSHA256,
|
|
FileLength: goproto.Uint64(resp.FileLength),
|
|
Url: &resp.URL,
|
|
ContextInfo: ctx,
|
|
}
|
|
|
|
b.Log.Debugf("=> Sending %#v as a document", msg)
|
|
|
|
ID := whatsmeow.GenerateMessageID()
|
|
_, err = b.wc.SendMessage(context.TODO(), groupJID, &message, whatsmeow.SendRequestExtra{ID: ID})
|
|
|
|
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) {
|
|
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
|
|
var ctx *proto.ContextInfo
|
|
if msg.ParentID != "" {
|
|
ctx, _ = b.getNewReplyContext(msg.ParentID)
|
|
}
|
|
|
|
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,
|
|
ContextInfo: ctx,
|
|
}
|
|
|
|
b.Log.Debugf("=> Sending %#v as an image", msg)
|
|
|
|
return b.sendMessage(msg, &message)
|
|
}
|
|
|
|
// Post a video message from the bridge to WhatsApp
|
|
func (b *Bwhatsapp) PostVideoMessage(msg config.Message, filetype string) (string, error) {
|
|
fi := msg.Extra["file"][0].(config.FileInfo)
|
|
|
|
caption := msg.Username + fi.Comment
|
|
|
|
resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaVideo)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var message proto.Message
|
|
var ctx *proto.ContextInfo
|
|
if msg.ParentID != "" {
|
|
ctx, _ = b.getNewReplyContext(msg.ParentID)
|
|
}
|
|
|
|
message.VideoMessage = &proto.VideoMessage{
|
|
Mimetype: &filetype,
|
|
Caption: &caption,
|
|
MediaKey: resp.MediaKey,
|
|
FileEncSha256: resp.FileEncSHA256,
|
|
FileSha256: resp.FileSHA256,
|
|
FileLength: goproto.Uint64(resp.FileLength),
|
|
Url: &resp.URL,
|
|
ContextInfo: ctx,
|
|
}
|
|
|
|
b.Log.Debugf("=> Sending %#v as a video", msg)
|
|
|
|
return b.sendMessage(msg, &message)
|
|
}
|
|
|
|
// Post audio inline
|
|
func (b *Bwhatsapp) PostAudioMessage(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.MediaAudio)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var message proto.Message
|
|
var ctx *proto.ContextInfo
|
|
if msg.ParentID != "" {
|
|
ctx, _ = b.getNewReplyContext(msg.ParentID)
|
|
}
|
|
|
|
message.AudioMessage = &proto.AudioMessage{
|
|
Mimetype: &filetype,
|
|
MediaKey: resp.MediaKey,
|
|
FileEncSha256: resp.FileEncSHA256,
|
|
FileSha256: resp.FileSHA256,
|
|
FileLength: goproto.Uint64(resp.FileLength),
|
|
Url: &resp.URL,
|
|
ContextInfo: ctx,
|
|
}
|
|
|
|
b.Log.Debugf("=> Sending %#v as audio", msg)
|
|
|
|
ID, err := b.sendMessage(msg, &message)
|
|
|
|
var captionMessage proto.Message
|
|
caption := msg.Username + fi.Comment + "\u2B06" // the char on the end is upwards arrow emoji
|
|
captionMessage.Conversation = &caption
|
|
|
|
captionID := whatsmeow.GenerateMessageID()
|
|
_, err = b.wc.SendMessage(context.TODO(), groupJID, &captionMessage, whatsmeow.SendRequestExtra{ID: captionID})
|
|
|
|
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)
|
|
|
|
extendedMsgID, _ := b.parseMessageID(msg.ID)
|
|
msg.ID = extendedMsgID.MessageID
|
|
|
|
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)
|
|
case "video/mp4", "video/3gpp": // TODO: Check if codecs are supported by WA
|
|
return b.PostVideoMessage(msg, filetype)
|
|
case "audio/ogg":
|
|
return b.PostAudioMessage(msg, "audio/ogg; codecs=opus") // TODO: Detect if it is actually OPUS
|
|
case "audio/aac", "audio/mp4", "audio/amr", "audio/mpeg":
|
|
return b.PostAudioMessage(msg, filetype)
|
|
default:
|
|
return b.PostDocumentMessage(msg, filetype)
|
|
}
|
|
}
|
|
|
|
var message proto.Message
|
|
text := msg.Username + msg.Text
|
|
|
|
// If we have a parent ID send an extended message
|
|
if msg.ParentID != "" {
|
|
replyContext, err := b.getNewReplyContext(msg.ParentID)
|
|
|
|
if err == nil {
|
|
message = proto.Message{
|
|
ExtendedTextMessage: &proto.ExtendedTextMessage{
|
|
Text: &text,
|
|
ContextInfo: replyContext,
|
|
},
|
|
}
|
|
|
|
return b.sendMessage(msg, &message)
|
|
}
|
|
}
|
|
|
|
message.Conversation = &text
|
|
|
|
return b.sendMessage(msg, &message)
|
|
}
|
|
|
|
func (b *Bwhatsapp) sendMessage(rmsg config.Message, message *proto.Message) (string, error) {
|
|
groupJID, _ := types.ParseJID(rmsg.Channel)
|
|
ID := whatsmeow.GenerateMessageID()
|
|
|
|
_, err := b.wc.SendMessage(context.Background(), groupJID, message, whatsmeow.SendRequestExtra{ID: ID})
|
|
|
|
return getMessageIdFormat(*b.wc.Store.ID, ID), err
|
|
}
|