| 1 | package bwhatsapp |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "mime" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/matterbridge-org/matterbridge/bridge" |
| 13 | "github.com/matterbridge-org/matterbridge/bridge/config" |
| 14 | "github.com/mdp/qrterminal" |
| 15 | |
| 16 | "go.mau.fi/whatsmeow" |
| 17 | "go.mau.fi/whatsmeow/binary/proto" |
| 18 | "go.mau.fi/whatsmeow/types" |
| 19 | waLog "go.mau.fi/whatsmeow/util/log" |
| 20 | |
| 21 | goproto "google.golang.org/protobuf/proto" |
| 22 | |
| 23 | _ "modernc.org/sqlite" // needed for sqlite |
| 24 | ) |
| 25 | |
| 26 | const ( |
| 27 | // Account config parameters |
| 28 | cfgNumber = "Number" |
| 29 | ) |
| 30 | |
| 31 | // Bwhatsapp Bridge structure keeping all the information needed for relying |
| 32 | type Bwhatsapp struct { |
| 33 | *bridge.Config |
| 34 | |
| 35 | startedAt time.Time |
| 36 | wc *whatsmeow.Client |
| 37 | contacts map[types.JID]types.ContactInfo |
| 38 | users map[string]types.ContactInfo |
| 39 | userAvatars map[string]string |
| 40 | joinedGroups []*types.GroupInfo |
| 41 | } |
| 42 | |
| 43 | type Replyable struct { |
| 44 | MessageID types.MessageID |
| 45 | Sender types.JID |
| 46 | } |
| 47 | |
| 48 | // New Create a new WhatsApp bridge. This will be called for each [whatsapp.<server>] entry you have in the config file |
| 49 | func New(cfg *bridge.Config) bridge.Bridger { |
| 50 | number := cfg.GetString(cfgNumber) |
| 51 | |
| 52 | if number == "" { |
| 53 | cfg.Log.Fatalf("Missing configuration for WhatsApp bridge: Number") |
| 54 | } |
| 55 | |
| 56 | b := &Bwhatsapp{ |
| 57 | Config: cfg, |
| 58 | |
| 59 | users: make(map[string]types.ContactInfo), |
| 60 | userAvatars: make(map[string]string), |
| 61 | } |
| 62 | |
| 63 | return b |
| 64 | } |
| 65 | |
| 66 | // Connect to WhatsApp. Required implementation of the Bridger interface |
| 67 | func (b *Bwhatsapp) Connect() error { |
| 68 | device, err := b.getDevice() |
| 69 | if err != nil { |
| 70 | return err |
| 71 | } |
| 72 | |
| 73 | number := b.GetString(cfgNumber) |
| 74 | if number == "" { |
| 75 | return errors.New("whatsapp's telephone number need to be configured") |
| 76 | } |
| 77 | |
| 78 | b.Log.Debugln("Connecting to WhatsApp..") |
| 79 | |
| 80 | b.wc = whatsmeow.NewClient(device, waLog.Stdout("Client", "INFO", true)) |
| 81 | b.wc.AddEventHandler(b.eventHandler) |
| 82 | |
| 83 | firstlogin := false |
| 84 | var qrChan <-chan whatsmeow.QRChannelItem |
| 85 | if b.wc.Store.ID == nil { |
| 86 | firstlogin = true |
| 87 | qrChan, err = b.wc.GetQRChannel(context.Background()) |
| 88 | if err != nil && !errors.Is(err, whatsmeow.ErrQRStoreContainsID) { |
| 89 | return errors.New("failed to to get QR channel:" + err.Error()) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | err = b.wc.Connect() |
| 94 | if err != nil { |
| 95 | return errors.New("failed to connect to WhatsApp: " + err.Error()) |
| 96 | } |
| 97 | |
| 98 | if b.wc.Store.ID == nil { |
| 99 | for evt := range qrChan { |
| 100 | if evt.Event == "code" { |
| 101 | qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) |
| 102 | } else { |
| 103 | b.Log.Infof("QR channel result: %s", evt.Event) |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // disconnect and reconnect on our first login/pairing |
| 109 | // for some reason the GetJoinedGroups in JoinChannel doesn't work on first login |
| 110 | if firstlogin { |
| 111 | b.wc.Disconnect() |
| 112 | time.Sleep(time.Second) |
| 113 | |
| 114 | err = b.wc.Connect() |
| 115 | if err != nil { |
| 116 | return errors.New("failed to connect to WhatsApp: " + err.Error()) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | b.Log.Infoln("WhatsApp connection successful") |
| 121 | |
| 122 | b.contacts, err = b.wc.Store.Contacts.GetAllContacts(context.Background()) |
| 123 | if err != nil { |
| 124 | return errors.New("failed to get contacts: " + err.Error()) |
| 125 | } |
| 126 | |
| 127 | b.joinedGroups, err = b.wc.GetJoinedGroups(context.Background()) |
| 128 | if err != nil { |
| 129 | return errors.New("failed to get list of joined groups: " + err.Error()) |
| 130 | } |
| 131 | |
| 132 | b.startedAt = time.Now() |
| 133 | |
| 134 | // map all the users |
| 135 | for id, contact := range b.contacts { |
| 136 | if !isGroupJid(id.String()) && id.String() != "status@broadcast" { |
| 137 | // it is user |
| 138 | b.users[id.String()] = contact |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // get user avatar asynchronously |
| 143 | b.Log.Info("Getting user avatars..") |
| 144 | |
| 145 | for jid := range b.users { |
| 146 | info, err := b.GetProfilePicThumb(jid) |
| 147 | if err != nil { |
| 148 | b.Log.Warnf("Could not get profile photo of %s: %v", jid, err) |
| 149 | } else { |
| 150 | b.Lock() |
| 151 | if info != nil { |
| 152 | b.userAvatars[jid] = info.URL |
| 153 | } |
| 154 | b.Unlock() |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | b.Log.Info("Finished getting avatars..") |
| 159 | |
| 160 | return nil |
| 161 | } |
| 162 | |
| 163 | // Disconnect is called while reconnecting to the bridge |
| 164 | // Required implementation of the Bridger interface |
| 165 | func (b *Bwhatsapp) Disconnect() error { |
| 166 | b.wc.Disconnect() |
| 167 | |
| 168 | return nil |
| 169 | } |
| 170 | |
| 171 | // JoinChannel Join a WhatsApp group specified in gateway config as channel='number-id@g.us' or channel='Channel name' |
| 172 | // Required implementation of the Bridger interface |
| 173 | // https://github.com/42wim/matterbridge/blob/2cfd880cdb0df29771bf8f31df8d990ab897889d/bridge/bridge.go#L11-L16 |
| 174 | func (b *Bwhatsapp) JoinChannel(channel config.ChannelInfo) error { |
| 175 | byJid := isGroupJid(channel.Name) |
| 176 | |
| 177 | // verify if we are member of the given group |
| 178 | if byJid { |
| 179 | gJID, err := types.ParseJID(channel.Name) |
| 180 | if err != nil { |
| 181 | return err |
| 182 | } |
| 183 | |
| 184 | for _, group := range b.joinedGroups { |
| 185 | if group.JID == gJID { |
| 186 | return nil |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | foundGroups := []string{} |
| 192 | |
| 193 | for _, group := range b.joinedGroups { |
| 194 | if group.Name == channel.Name { |
| 195 | foundGroups = append(foundGroups, group.Name) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | switch len(foundGroups) { |
| 200 | case 0: |
| 201 | // didn't match any group - print out possibilites |
| 202 | for _, group := range b.joinedGroups { |
| 203 | b.Log.Infof("%s %s", group.JID, group.Name) |
| 204 | } |
| 205 | return fmt.Errorf("please specify group's JID from the list above instead of the name '%s'", channel.Name) |
| 206 | case 1: |
| 207 | return fmt.Errorf("group name might change. Please configure gateway with channel=\"%v\" instead of channel=\"%v\"", foundGroups[0], channel.Name) |
| 208 | default: |
| 209 | return fmt.Errorf("there is more than one group with name '%s'. Please specify one of JIDs as channel name: %v", channel.Name, foundGroups) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // Post a document message from the bridge to WhatsApp |
| 214 | func (b *Bwhatsapp) PostDocumentMessage(msg config.Message, filetype string) (string, error) { |
| 215 | groupJID, _ := types.ParseJID(msg.Channel) |
| 216 | |
| 217 | fi := msg.Extra["file"][0].(config.FileInfo) |
| 218 | |
| 219 | caption := msg.Username + fi.Comment |
| 220 | |
| 221 | resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaDocument) |
| 222 | if err != nil { |
| 223 | return "", err |
| 224 | } |
| 225 | |
| 226 | // Post document message |
| 227 | var message proto.Message |
| 228 | var ctx *proto.ContextInfo |
| 229 | if msg.ParentID != "" { |
| 230 | ctx, _ = b.getNewReplyContext(msg.ParentID) |
| 231 | } |
| 232 | |
| 233 | message.DocumentMessage = &proto.DocumentMessage{ |
| 234 | Title: &fi.Name, |
| 235 | FileName: &fi.Name, |
| 236 | Mimetype: &filetype, |
| 237 | Caption: &caption, |
| 238 | MediaKey: resp.MediaKey, |
| 239 | FileEncSHA256: resp.FileEncSHA256, |
| 240 | FileSHA256: resp.FileSHA256, |
| 241 | FileLength: goproto.Uint64(resp.FileLength), |
| 242 | URL: &resp.URL, |
| 243 | DirectPath: &resp.DirectPath, |
| 244 | ContextInfo: ctx, |
| 245 | } |
| 246 | |
| 247 | b.Log.Debugf("=> Sending %#v as a document", msg) |
| 248 | |
| 249 | ID := whatsmeow.GenerateMessageID() |
| 250 | _, err = b.wc.SendMessage(context.TODO(), groupJID, &message, whatsmeow.SendRequestExtra{ID: ID}) |
| 251 | |
| 252 | return ID, err |
| 253 | } |
| 254 | |
| 255 | // Post an image message from the bridge to WhatsApp |
| 256 | // Handle, for sure image/jpeg, image/png and image/gif MIME types |
| 257 | func (b *Bwhatsapp) PostImageMessage(msg config.Message, filetype string) (string, error) { |
| 258 | fi := msg.Extra["file"][0].(config.FileInfo) |
| 259 | |
| 260 | caption := msg.Username + fi.Comment |
| 261 | |
| 262 | resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaImage) |
| 263 | if err != nil { |
| 264 | return "", err |
| 265 | } |
| 266 | |
| 267 | var message proto.Message |
| 268 | var ctx *proto.ContextInfo |
| 269 | if msg.ParentID != "" { |
| 270 | ctx, _ = b.getNewReplyContext(msg.ParentID) |
| 271 | } |
| 272 | |
| 273 | message.ImageMessage = &proto.ImageMessage{ |
| 274 | Mimetype: &filetype, |
| 275 | Caption: &caption, |
| 276 | MediaKey: resp.MediaKey, |
| 277 | FileEncSHA256: resp.FileEncSHA256, |
| 278 | FileSHA256: resp.FileSHA256, |
| 279 | FileLength: goproto.Uint64(resp.FileLength), |
| 280 | URL: &resp.URL, |
| 281 | DirectPath: &resp.DirectPath, |
| 282 | ContextInfo: ctx, |
| 283 | } |
| 284 | |
| 285 | b.Log.Debugf("=> Sending %#v as an image", msg) |
| 286 | |
| 287 | return b.sendMessage(msg, &message) |
| 288 | } |
| 289 | |
| 290 | // Post a video message from the bridge to WhatsApp |
| 291 | func (b *Bwhatsapp) PostVideoMessage(msg config.Message, filetype string) (string, error) { |
| 292 | fi := msg.Extra["file"][0].(config.FileInfo) |
| 293 | |
| 294 | caption := msg.Username + fi.Comment |
| 295 | |
| 296 | resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaVideo) |
| 297 | if err != nil { |
| 298 | return "", err |
| 299 | } |
| 300 | |
| 301 | var message proto.Message |
| 302 | var ctx *proto.ContextInfo |
| 303 | if msg.ParentID != "" { |
| 304 | ctx, _ = b.getNewReplyContext(msg.ParentID) |
| 305 | } |
| 306 | |
| 307 | message.VideoMessage = &proto.VideoMessage{ |
| 308 | Mimetype: &filetype, |
| 309 | Caption: &caption, |
| 310 | MediaKey: resp.MediaKey, |
| 311 | FileEncSHA256: resp.FileEncSHA256, |
| 312 | FileSHA256: resp.FileSHA256, |
| 313 | FileLength: goproto.Uint64(resp.FileLength), |
| 314 | URL: &resp.URL, |
| 315 | DirectPath: &resp.DirectPath, |
| 316 | ContextInfo: ctx, |
| 317 | } |
| 318 | |
| 319 | b.Log.Debugf("=> Sending %#v as a video", msg) |
| 320 | |
| 321 | return b.sendMessage(msg, &message) |
| 322 | } |
| 323 | |
| 324 | // Post audio inline |
| 325 | func (b *Bwhatsapp) PostAudioMessage(msg config.Message, filetype string) (string, error) { |
| 326 | groupJID, _ := types.ParseJID(msg.Channel) |
| 327 | |
| 328 | fi := msg.Extra["file"][0].(config.FileInfo) |
| 329 | |
| 330 | resp, err := b.wc.Upload(context.Background(), *fi.Data, whatsmeow.MediaAudio) |
| 331 | if err != nil { |
| 332 | return "", err |
| 333 | } |
| 334 | |
| 335 | var message proto.Message |
| 336 | var ctx *proto.ContextInfo |
| 337 | if msg.ParentID != "" { |
| 338 | ctx, _ = b.getNewReplyContext(msg.ParentID) |
| 339 | } |
| 340 | |
| 341 | message.AudioMessage = &proto.AudioMessage{ |
| 342 | Mimetype: &filetype, |
| 343 | MediaKey: resp.MediaKey, |
| 344 | FileEncSHA256: resp.FileEncSHA256, |
| 345 | FileSHA256: resp.FileSHA256, |
| 346 | FileLength: goproto.Uint64(resp.FileLength), |
| 347 | URL: &resp.URL, |
| 348 | DirectPath: &resp.DirectPath, |
| 349 | ContextInfo: ctx, |
| 350 | } |
| 351 | |
| 352 | b.Log.Debugf("=> Sending %#v as audio", msg) |
| 353 | |
| 354 | ID, err := b.sendMessage(msg, &message) |
| 355 | |
| 356 | var captionMessage proto.Message |
| 357 | caption := msg.Username + fi.Comment + "\u2B06" // the char on the end is upwards arrow emoji |
| 358 | captionMessage.Conversation = &caption |
| 359 | |
| 360 | captionID := whatsmeow.GenerateMessageID() |
| 361 | _, err = b.wc.SendMessage(context.TODO(), groupJID, &captionMessage, whatsmeow.SendRequestExtra{ID: captionID}) |
| 362 | |
| 363 | return ID, err |
| 364 | } |
| 365 | |
| 366 | // Send a message from the bridge to WhatsApp |
| 367 | func (b *Bwhatsapp) Send(msg config.Message) (string, error) { |
| 368 | groupJID, _ := types.ParseJID(msg.Channel) |
| 369 | |
| 370 | extendedMsgID, _ := b.parseMessageID(msg.ID) |
| 371 | msg.ID = extendedMsgID.MessageID |
| 372 | |
| 373 | b.Log.Debugf("=> Receiving %#v", msg) |
| 374 | |
| 375 | // Delete message |
| 376 | if msg.Event == config.EventMsgDelete { |
| 377 | if msg.ID == "" { |
| 378 | // No message ID in case action is executed on a message sent before the bridge was started |
| 379 | // and then the bridge cache doesn't have this message ID mapped |
| 380 | return "", nil |
| 381 | } |
| 382 | |
| 383 | _, err := b.wc.RevokeMessage(context.Background(), groupJID, msg.ID) |
| 384 | |
| 385 | return "", err |
| 386 | } |
| 387 | |
| 388 | // Edit message |
| 389 | if msg.ID != "" { |
| 390 | b.Log.Debugf("updating message with id %s", msg.ID) |
| 391 | |
| 392 | if b.GetString("editsuffix") != "" { |
| 393 | msg.Text += b.GetString("EditSuffix") |
| 394 | } else { |
| 395 | msg.Text += " (edited)" |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | // Handle Upload a file |
| 400 | if msg.Extra["file"] != nil { |
| 401 | fi := msg.Extra["file"][0].(config.FileInfo) |
| 402 | filetype := mime.TypeByExtension(filepath.Ext(fi.Name)) |
| 403 | |
| 404 | b.Log.Debugf("Extra file is %#v", filetype) |
| 405 | |
| 406 | // TODO: add different types |
| 407 | // TODO: add webp conversion |
| 408 | switch filetype { |
| 409 | case "image/jpeg", "image/png", "image/gif": |
| 410 | return b.PostImageMessage(msg, filetype) |
| 411 | case "video/mp4", "video/3gpp": // TODO: Check if codecs are supported by WA |
| 412 | return b.PostVideoMessage(msg, filetype) |
| 413 | case "audio/ogg": |
| 414 | return b.PostAudioMessage(msg, "audio/ogg; codecs=opus") // TODO: Detect if it is actually OPUS |
| 415 | case "audio/aac", "audio/mp4", "audio/amr", "audio/mpeg": |
| 416 | return b.PostAudioMessage(msg, filetype) |
| 417 | default: |
| 418 | return b.PostDocumentMessage(msg, filetype) |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | var message proto.Message |
| 423 | text := msg.Username + msg.Text |
| 424 | |
| 425 | // If we have a parent ID send an extended message |
| 426 | if msg.ParentID != "" { |
| 427 | replyContext, err := b.getNewReplyContext(msg.ParentID) |
| 428 | |
| 429 | if err == nil { |
| 430 | message = proto.Message{ |
| 431 | ExtendedTextMessage: &proto.ExtendedTextMessage{ |
| 432 | Text: &text, |
| 433 | ContextInfo: replyContext, |
| 434 | }, |
| 435 | } |
| 436 | |
| 437 | return b.sendMessage(msg, &message) |
| 438 | } |
| 439 | } |
| 440 | |
| 441 | message.Conversation = &text |
| 442 | |
| 443 | return b.sendMessage(msg, &message) |
| 444 | } |
| 445 | |
| 446 | func (b *Bwhatsapp) sendMessage(rmsg config.Message, message *proto.Message) (string, error) { |
| 447 | groupJID, _ := types.ParseJID(rmsg.Channel) |
| 448 | ID := whatsmeow.GenerateMessageID() |
| 449 | |
| 450 | _, err := b.wc.SendMessage(context.Background(), groupJID, message, whatsmeow.SendRequestExtra{ID: ID}) |
| 451 | |
| 452 | return getMessageIdFormat(*b.wc.Store.ID, ID), err |
| 453 | } |
| 454 | |