Thumbnail

rani/matterbridge.git

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

Viewing file on branch master

1package bwhatsapp
2
3import (
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
26const (
27 // Account config parameters
28 cfgNumber = "Number"
29)
30
31// Bwhatsapp Bridge structure keeping all the information needed for relying
32type 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
43type 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
49func 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
67func (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
165func (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
174func (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
214func (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
257func (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
291func (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
325func (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
367func (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
446func (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