slidge-whatsapp 0.2.2__cp312-cp312-manylinux_2_36_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of slidge-whatsapp might be problematic. Click here for more details.

@@ -0,0 +1,855 @@
1
+ package whatsapp
2
+
3
+ import (
4
+ // Standard library.
5
+ "bytes"
6
+ "context"
7
+ "errors"
8
+ "fmt"
9
+ "image/jpeg"
10
+ "math/rand"
11
+ "time"
12
+
13
+ // Internal packages.
14
+ "codeberg.org/slidge/slidge-whatsapp/slidge_whatsapp/media"
15
+
16
+ // Third-party libraries.
17
+ _ "github.com/mattn/go-sqlite3"
18
+ "go.mau.fi/whatsmeow"
19
+ "go.mau.fi/whatsmeow/appstate"
20
+ "go.mau.fi/whatsmeow/proto/waCommon"
21
+ "go.mau.fi/whatsmeow/proto/waE2E"
22
+ "go.mau.fi/whatsmeow/proto/waHistorySync"
23
+ "go.mau.fi/whatsmeow/store"
24
+ "go.mau.fi/whatsmeow/types"
25
+ "go.mau.fi/whatsmeow/types/events"
26
+ )
27
+
28
+ const (
29
+ // The default host part for user JIDs on WhatsApp.
30
+ DefaultUserServer = types.DefaultUserServer
31
+
32
+ // The default host part for group JIDs on WhatsApp.
33
+ DefaultGroupServer = types.GroupServer
34
+
35
+ // The number of times keep-alive checks can fail before attempting to re-connect the session.
36
+ keepAliveFailureThreshold = 3
37
+
38
+ // The minimum and maximum wait interval between connection retries after keep-alive check failure.
39
+ keepAliveMinRetryInterval = 5 * time.Second
40
+ keepAliveMaxRetryInterval = 5 * time.Minute
41
+
42
+ // The amount of time to wait before re-requesting contact presences WhatsApp. This is required
43
+ // since otherwise WhatsApp will assume that you're inactive, and will stop sending presence
44
+ // updates for contacts and groups. By default, this interval has a jitter of ± half its value
45
+ // (e.g. for an initial interval of 2 hours, the final value will range from 1 to 3 hours) in
46
+ // order to provide a more natural interaction with remote WhatsApp servers.
47
+ presenceRefreshInterval = 12 * time.Hour
48
+
49
+ // The maximum number of messages to request at a time when performing on-demand history
50
+ // synchronization.
51
+ maxHistorySyncMessages = 50
52
+ )
53
+
54
+ // A Session represents a connection (active or not) between a linked device and WhatsApp. Active
55
+ // sessions need to be established by logging in, after which incoming events will be forwarded to
56
+ // the adapter event handler, and outgoing events will be forwarded to WhatsApp.
57
+ type Session struct {
58
+ device LinkedDevice // The linked device this session corresponds to.
59
+ client *whatsmeow.Client // The concrete client connection to WhatsApp for this session.
60
+ gateway *Gateway // The Gateway this Session is attached to.
61
+ eventHandler HandleEventFunc // The handler function to use for propagating events to the adapter.
62
+ presenceChan chan PresenceKind // A channel used for periodically refreshing contact presences.
63
+ }
64
+
65
+ // Login attempts to authenticate the given [Session], either by re-using the [LinkedDevice] attached
66
+ // or by initiating a pairing session for a new linked device. Callers are expected to have set an
67
+ // event handler in order to receive any incoming events from the underlying WhatsApp session.
68
+ func (s *Session) Login() error {
69
+ var err error
70
+ var store *store.Device
71
+
72
+ // Try to fetch existing device from given device JID.
73
+ if s.device.ID != "" {
74
+ store, err = s.gateway.container.GetDevice(s.device.JID())
75
+ if err != nil {
76
+ return err
77
+ }
78
+ }
79
+
80
+ if store == nil {
81
+ store = s.gateway.container.NewDevice()
82
+ }
83
+
84
+ s.client = whatsmeow.NewClient(store, s.gateway.logger)
85
+ s.client.AddEventHandler(s.handleEvent)
86
+ s.client.AutomaticMessageRerequestFromPhone = true
87
+
88
+ // Refresh contact presences on a set interval, to avoid issues with WhatsApp dropping them
89
+ // entirely. Contact presences are refreshed only if our current status is set to "available";
90
+ // otherwise, a refresh is queued up for whenever our status changes back to "available".
91
+ s.presenceChan = make(chan PresenceKind, 1)
92
+ go func() {
93
+ var newTimer = func(d time.Duration) *time.Timer {
94
+ return time.NewTimer(d + time.Duration(rand.Int63n(int64(d))-int64(d/2)))
95
+ }
96
+ var timer, timerStopped = newTimer(presenceRefreshInterval), false
97
+ var presence = PresenceAvailable
98
+ for {
99
+ select {
100
+ case <-timer.C:
101
+ if presence == PresenceAvailable {
102
+ _, _ = s.GetContacts(false)
103
+ timer, timerStopped = newTimer(presenceRefreshInterval), false
104
+ } else {
105
+ timerStopped = true
106
+ }
107
+ case p, ok := <-s.presenceChan:
108
+ if !ok && !timerStopped {
109
+ if !timer.Stop() {
110
+ <-timer.C
111
+ }
112
+ return
113
+ } else if timerStopped && p == PresenceAvailable {
114
+ _, _ = s.GetContacts(false)
115
+ timer, timerStopped = newTimer(presenceRefreshInterval), false
116
+ }
117
+ presence = p
118
+ }
119
+ }
120
+ }()
121
+
122
+ // Simply connect our client if already registered.
123
+ if s.client.Store.ID != nil {
124
+ return s.client.Connect()
125
+ }
126
+
127
+ // Attempt out-of-band registration of client via QR code.
128
+ qrChan, _ := s.client.GetQRChannel(context.Background())
129
+ if err = s.client.Connect(); err != nil {
130
+ return err
131
+ }
132
+
133
+ go func() {
134
+ for e := range qrChan {
135
+ if !s.client.IsConnected() {
136
+ return
137
+ }
138
+ switch e.Event {
139
+ case whatsmeow.QRChannelEventCode:
140
+ s.propagateEvent(EventQRCode, &EventPayload{QRCode: e.Code})
141
+ case whatsmeow.QRChannelEventError:
142
+ s.propagateEvent(EventConnect, &EventPayload{Connect: Connect{Error: e.Error.Error()}})
143
+ }
144
+ }
145
+ }()
146
+
147
+ return nil
148
+ }
149
+
150
+ // Logout disconnects and removes the current linked device locally and initiates a logout remotely.
151
+ func (s *Session) Logout() error {
152
+ if s.client == nil || s.client.Store.ID == nil {
153
+ return nil
154
+ }
155
+
156
+ err := s.client.Logout()
157
+ s.client = nil
158
+ close(s.presenceChan)
159
+
160
+ return err
161
+ }
162
+
163
+ // Disconnects detaches the current connection to WhatsApp without removing any linked device state.
164
+ func (s *Session) Disconnect() error {
165
+ if s.client != nil {
166
+ s.client.Disconnect()
167
+ s.client = nil
168
+ close(s.presenceChan)
169
+ }
170
+
171
+ return nil
172
+ }
173
+
174
+ // PairPhone returns a one-time code from WhatsApp, used for pairing this [Session] against the
175
+ // user's primary device, as identified by the given phone number. This will return an error if the
176
+ // [Session] is already paired, or if the phone number given is empty or invalid.
177
+ func (s *Session) PairPhone(phone string) (string, error) {
178
+ if s.client == nil {
179
+ return "", fmt.Errorf("Cannot pair for uninitialized session")
180
+ } else if s.client.Store.ID != nil {
181
+ return "", fmt.Errorf("Refusing to pair for connected session")
182
+ } else if phone == "" {
183
+ return "", fmt.Errorf("Cannot pair for empty phone number")
184
+ }
185
+
186
+ code, err := s.client.PairPhone(phone, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
187
+ if err != nil {
188
+ return "", fmt.Errorf("Failed to pair with phone number: %s", err)
189
+ }
190
+
191
+ return code, nil
192
+ }
193
+
194
+ // SendMessage processes the given Message and sends a WhatsApp message for the kind and contact JID
195
+ // specified within. In general, different message kinds require different fields to be set; see the
196
+ // documentation for the [Message] type for more information.
197
+ func (s *Session) SendMessage(message Message) error {
198
+ if s.client == nil || s.client.Store.ID == nil {
199
+ return fmt.Errorf("Cannot send message for unauthenticated session")
200
+ }
201
+
202
+ jid, err := types.ParseJID(message.JID)
203
+ if err != nil {
204
+ return fmt.Errorf("Could not parse sender JID for message: %s", err)
205
+ }
206
+
207
+ var payload *waE2E.Message
208
+ var extra whatsmeow.SendRequestExtra
209
+
210
+ switch message.Kind {
211
+ case MessageAttachment:
212
+ // Handle message with attachment, if any.
213
+ if len(message.Attachments) == 0 {
214
+ return nil
215
+ }
216
+
217
+ // Upload attachment into WhatsApp before sending message.
218
+ if payload, err = uploadAttachment(s.client, &message.Attachments[0]); err != nil {
219
+ return fmt.Errorf("Failed uploading attachment: %s", err)
220
+ }
221
+ extra.ID = message.ID
222
+ case MessageEdit:
223
+ // Edit existing message by ID.
224
+ payload = s.client.BuildEdit(s.device.JID().ToNonAD(), message.ID, s.getMessagePayload(message))
225
+ case MessageRevoke:
226
+ // Don't send message, but revoke existing message by ID.
227
+ var originJID types.JID
228
+ if message.OriginJID == "" {
229
+ // A message retraction by the person who sent it
230
+ originJID = types.EmptyJID
231
+ } else {
232
+ // A message moderation
233
+ originJID, err = types.ParseJID(message.OriginJID)
234
+ if err != nil {
235
+ return fmt.Errorf("Could not parse sender JID for message: %s", err)
236
+ }
237
+ }
238
+ payload = s.client.BuildRevoke(jid, originJID, message.ID)
239
+ case MessageReaction:
240
+ // Send message as emoji reaction to a given message.
241
+ payload = &waE2E.Message{
242
+ ReactionMessage: &waE2E.ReactionMessage{
243
+ Key: &waCommon.MessageKey{
244
+ RemoteJID: &message.JID,
245
+ FromMe: &message.IsCarbon,
246
+ ID: &message.ID,
247
+ Participant: &message.OriginJID,
248
+ },
249
+ Text: &message.Body,
250
+ SenderTimestampMS: ptrTo(time.Now().UnixMilli()),
251
+ },
252
+ }
253
+ default:
254
+ payload = s.getMessagePayload(message)
255
+ extra.ID = message.ID
256
+ }
257
+
258
+ s.gateway.logger.Debugf("Sending message to JID '%s': %+v", jid, payload)
259
+ _, err = s.client.SendMessage(context.Background(), jid, payload, extra)
260
+ return err
261
+ }
262
+
263
+ const (
264
+ // The maximum size thumbnail image we'll send in outgoing URL preview messages.
265
+ maxPreviewThumbnailSize = 1024 * 500 // 500KiB
266
+ )
267
+
268
+ // GetMessagePayload returns a concrete WhatsApp protocol message for the given Message representation.
269
+ // The specific fields set within the protocol message, as well as its type, can depend on specific
270
+ // fields set in the Message type, and may be nested recursively (e.g. when replying to a reply).
271
+ func (s *Session) getMessagePayload(message Message) *waE2E.Message {
272
+ var payload *waE2E.Message
273
+ var ctx = context.Background()
274
+
275
+ // Compose extended message when made as a reply to a different message.
276
+ if message.ReplyID != "" {
277
+ // Fall back to our own JID if no origin JID has been specified, in which case we assume
278
+ // we're replying to our own messages.
279
+ if message.OriginJID == "" {
280
+ message.OriginJID = s.device.JID().ToNonAD().String()
281
+ }
282
+ payload = &waE2E.Message{
283
+ ExtendedTextMessage: &waE2E.ExtendedTextMessage{
284
+ Text: &message.Body,
285
+ ContextInfo: &waE2E.ContextInfo{
286
+ StanzaID: &message.ReplyID,
287
+ QuotedMessage: &waE2E.Message{Conversation: ptrTo(message.ReplyBody)},
288
+ Participant: &message.OriginJID,
289
+ },
290
+ },
291
+ }
292
+ }
293
+
294
+ // Add URL preview, if any was given in message.
295
+ if message.Preview.URL != "" {
296
+ if payload == nil {
297
+ payload = &waE2E.Message{ExtendedTextMessage: &waE2E.ExtendedTextMessage{Text: &message.Body}}
298
+ }
299
+
300
+ switch message.Preview.Kind {
301
+ case PreviewPlain:
302
+ payload.ExtendedTextMessage.PreviewType = ptrTo(waE2E.ExtendedTextMessage_NONE)
303
+ case PreviewVideo:
304
+ payload.ExtendedTextMessage.PreviewType = ptrTo(waE2E.ExtendedTextMessage_VIDEO)
305
+ }
306
+
307
+ payload.ExtendedTextMessage.MatchedText = &message.Preview.URL
308
+ payload.ExtendedTextMessage.CanonicalURL = &message.Preview.URL
309
+ payload.ExtendedTextMessage.Title = &message.Preview.Title
310
+ payload.ExtendedTextMessage.Description = &message.Preview.Description
311
+
312
+ if len(message.Preview.Thumbnail) > 0 && len(message.Preview.Thumbnail) < maxPreviewThumbnailSize {
313
+ data, err := media.Convert(ctx, message.Preview.Thumbnail, &previewThumbnailSpec)
314
+ if err == nil {
315
+ payload.ExtendedTextMessage.JPEGThumbnail = data
316
+ if info, err := jpeg.DecodeConfig(bytes.NewReader(data)); err == nil {
317
+ payload.ExtendedTextMessage.ThumbnailWidth = ptrTo(uint32(info.Width))
318
+ payload.ExtendedTextMessage.ThumbnailHeight = ptrTo(uint32(info.Height))
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ // Attach any inline mentions extended metadata.
325
+ if len(message.MentionJIDs) > 0 {
326
+ if payload == nil {
327
+ payload = &waE2E.Message{ExtendedTextMessage: &waE2E.ExtendedTextMessage{Text: &message.Body}}
328
+ }
329
+ if payload.ExtendedTextMessage.ContextInfo == nil {
330
+ payload.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{}
331
+ }
332
+ payload.ExtendedTextMessage.ContextInfo.MentionedJID = message.MentionJIDs
333
+ }
334
+
335
+ // Process any location information in message, if possible.
336
+ if message.Location.Latitude > 0 || message.Location.Longitude > 0 {
337
+ if payload == nil {
338
+ payload = &waE2E.Message{LocationMessage: &waE2E.LocationMessage{}}
339
+ }
340
+ payload.LocationMessage.DegreesLatitude = &message.Location.Latitude
341
+ payload.LocationMessage.DegreesLongitude = &message.Location.Longitude
342
+ payload.LocationMessage.AccuracyInMeters = ptrTo(uint32(message.Location.Accuracy))
343
+ }
344
+
345
+ if payload == nil {
346
+ payload = &waE2E.Message{Conversation: &message.Body}
347
+ }
348
+
349
+ return payload
350
+ }
351
+
352
+ // GenerateMessageID returns a valid, pseudo-random message ID for use in outgoing messages.
353
+ func (s *Session) GenerateMessageID() string {
354
+ return s.client.GenerateMessageID()
355
+ }
356
+
357
+ // SendChatState sends the given chat state notification (e.g. composing message) to WhatsApp for the
358
+ // contact specified within.
359
+ func (s *Session) SendChatState(state ChatState) error {
360
+ if s.client == nil || s.client.Store.ID == nil {
361
+ return fmt.Errorf("Cannot send chat state for unauthenticated session")
362
+ }
363
+
364
+ jid, err := types.ParseJID(state.JID)
365
+ if err != nil {
366
+ return fmt.Errorf("Could not parse sender JID for chat state: %s", err)
367
+ }
368
+
369
+ var presence types.ChatPresence
370
+ switch state.Kind {
371
+ case ChatStateComposing:
372
+ presence = types.ChatPresenceComposing
373
+ case ChatStatePaused:
374
+ presence = types.ChatPresencePaused
375
+ }
376
+
377
+ return s.client.SendChatPresence(jid, presence, "")
378
+ }
379
+
380
+ // SendReceipt sends a read receipt to WhatsApp for the message IDs specified within.
381
+ func (s *Session) SendReceipt(receipt Receipt) error {
382
+ if s.client == nil || s.client.Store.ID == nil {
383
+ return fmt.Errorf("Cannot send receipt for unauthenticated session")
384
+ }
385
+
386
+ var jid, senderJID types.JID
387
+ var err error
388
+
389
+ if receipt.GroupJID != "" {
390
+ if senderJID, err = types.ParseJID(receipt.JID); err != nil {
391
+ return fmt.Errorf("Could not parse sender JID for receipt: %s", err)
392
+ } else if jid, err = types.ParseJID(receipt.GroupJID); err != nil {
393
+ return fmt.Errorf("Could not parse group JID for receipt: %s", err)
394
+ }
395
+ } else {
396
+ if jid, err = types.ParseJID(receipt.JID); err != nil {
397
+ return fmt.Errorf("Could not parse sender JID for receipt: %s", err)
398
+ }
399
+ }
400
+
401
+ ids := append([]types.MessageID{}, receipt.MessageIDs...)
402
+ return s.client.MarkRead(ids, time.Unix(receipt.Timestamp, 0), jid, senderJID)
403
+ }
404
+
405
+ // SendPresence sets the activity state and (optional) status message for the current session and
406
+ // user. An error is returned if setting availability fails for any reason.
407
+ func (s *Session) SendPresence(presence PresenceKind, statusMessage string) error {
408
+ if s.client == nil || s.client.Store.ID == nil {
409
+ return fmt.Errorf("Cannot send presence for unauthenticated session")
410
+ }
411
+
412
+ var err error
413
+ s.presenceChan <- presence
414
+
415
+ switch presence {
416
+ case PresenceAvailable:
417
+ err = s.client.SendPresence(types.PresenceAvailable)
418
+ case PresenceUnavailable:
419
+ err = s.client.SendPresence(types.PresenceUnavailable)
420
+ }
421
+
422
+ if err == nil && statusMessage != "" {
423
+ err = s.client.SetStatusMessage(statusMessage)
424
+ }
425
+
426
+ return err
427
+ }
428
+
429
+ // GetContacts subscribes to the WhatsApp roster currently stored in the Session's internal state.
430
+ // If `refresh` is `true`, FetchRoster will pull application state from the remote service and
431
+ // synchronize any contacts found with the adapter.
432
+ func (s *Session) GetContacts(refresh bool) ([]Contact, error) {
433
+ if s.client == nil || s.client.Store.ID == nil {
434
+ return nil, fmt.Errorf("Cannot get contacts for unauthenticated session")
435
+ }
436
+
437
+ // Synchronize remote application state with local state if requested.
438
+ if refresh {
439
+ err := s.client.FetchAppState(appstate.WAPatchCriticalUnblockLow, false, false)
440
+ if err != nil {
441
+ s.gateway.logger.Warnf("Could not get app state from server: %s", err)
442
+ }
443
+ }
444
+
445
+ // Synchronize local contact state with overarching gateway for all local contacts.
446
+ data, err := s.client.Store.Contacts.GetAllContacts()
447
+ if err != nil {
448
+ return nil, fmt.Errorf("Failed getting local contacts: %s", err)
449
+ }
450
+
451
+ var contacts []Contact
452
+ for jid, info := range data {
453
+ if err = s.client.SubscribePresence(jid); err != nil {
454
+ s.gateway.logger.Warnf("Failed to subscribe to presence for %s", jid)
455
+ }
456
+
457
+ _, c := newContactEvent(jid, info)
458
+ contacts = append(contacts, c.Contact)
459
+ }
460
+
461
+ return contacts, nil
462
+ }
463
+
464
+ // GetGroups returns a list of all group-chats currently joined in WhatsApp, along with additional
465
+ // information on present participants.
466
+ func (s *Session) GetGroups() ([]Group, error) {
467
+ if s.client == nil || s.client.Store.ID == nil {
468
+ return nil, fmt.Errorf("Cannot get groups for unauthenticated session")
469
+ }
470
+
471
+ data, err := s.client.GetJoinedGroups()
472
+ if err != nil {
473
+ return nil, fmt.Errorf("Failed getting groups: %s", err)
474
+ }
475
+
476
+ var groups []Group
477
+ for _, info := range data {
478
+ groups = append(groups, newGroup(s.client, info))
479
+ }
480
+
481
+ return groups, nil
482
+ }
483
+
484
+ // CreateGroup attempts to create a new WhatsApp group for the given human-readable name and
485
+ // participant JIDs given.
486
+ func (s *Session) CreateGroup(name string, participants []string) (Group, error) {
487
+ if s.client == nil || s.client.Store.ID == nil {
488
+ return Group{}, fmt.Errorf("Cannot create group for unauthenticated session")
489
+ }
490
+
491
+ var jids []types.JID
492
+ for _, p := range participants {
493
+ jid, err := types.ParseJID(p)
494
+ if err != nil {
495
+ return Group{}, fmt.Errorf("Could not parse participant JID: %s", err)
496
+ }
497
+
498
+ jids = append(jids, jid)
499
+ }
500
+
501
+ req := whatsmeow.ReqCreateGroup{Name: name, Participants: jids}
502
+ info, err := s.client.CreateGroup(req)
503
+ if err != nil {
504
+ return Group{}, fmt.Errorf("Could not create group: %s", err)
505
+ }
506
+
507
+ return newGroup(s.client, info), nil
508
+ }
509
+
510
+ // LeaveGroup attempts to remove our own user from the given WhatsApp group, for the JID given.
511
+ func (s *Session) LeaveGroup(resourceID string) error {
512
+ if s.client == nil || s.client.Store.ID == nil {
513
+ return fmt.Errorf("Cannot leave group for unauthenticated session")
514
+ }
515
+
516
+ jid, err := types.ParseJID(resourceID)
517
+ if err != nil {
518
+ return fmt.Errorf("Could not parse JID for leaving group: %s", err)
519
+ }
520
+
521
+ return s.client.LeaveGroup(jid)
522
+ }
523
+
524
+ // GetAvatar fetches a profile picture for the Contact or Group JID given. If a non-empty `avatarID`
525
+ // is also given, GetAvatar will return an empty [Avatar] instance with no error if the remote state
526
+ // for the given ID has not changed.
527
+ func (s *Session) GetAvatar(resourceID, avatarID string) (Avatar, error) {
528
+ if s.client == nil || s.client.Store.ID == nil {
529
+ return Avatar{}, fmt.Errorf("Cannot get avatar for unauthenticated session")
530
+ }
531
+
532
+ jid, err := types.ParseJID(resourceID)
533
+ if err != nil {
534
+ return Avatar{}, fmt.Errorf("Could not parse JID for avatar: %s", err)
535
+ }
536
+
537
+ p, err := s.client.GetProfilePictureInfo(jid, &whatsmeow.GetProfilePictureParams{ExistingID: avatarID})
538
+ if err != nil &&
539
+ !errors.Is(err, whatsmeow.ErrProfilePictureNotSet) &&
540
+ !errors.Is(err, whatsmeow.ErrProfilePictureUnauthorized) {
541
+ return Avatar{}, fmt.Errorf("Could not get avatar: %s", err)
542
+ } else if p != nil {
543
+ return Avatar{ID: p.ID, URL: p.URL}, nil
544
+ }
545
+
546
+ return Avatar{}, nil
547
+ }
548
+
549
+ // SetAvatar updates the profile picture for the Contact or Group JID given; it can also update the
550
+ // profile picture for our own user by providing an empty JID. The unique picture ID is returned,
551
+ // typically used as a cache reference or in providing to future calls for [Session.GetAvatar].
552
+ func (s *Session) SetAvatar(resourceID string, avatar []byte) (string, error) {
553
+ if s.client == nil || s.client.Store.ID == nil {
554
+ return "", fmt.Errorf("Cannot set avatar for unauthenticated session")
555
+ }
556
+
557
+ var ctx = context.Background()
558
+ var jid types.JID
559
+ var err error
560
+
561
+ // Setting the profile picture for the user expects an empty `resourceID`.
562
+ if resourceID == "" {
563
+ jid = types.EmptyJID
564
+ } else if jid, err = types.ParseJID(resourceID); err != nil {
565
+ return "", fmt.Errorf("Could not parse JID for avatar: %s", err)
566
+ }
567
+
568
+ if len(avatar) == 0 {
569
+ return s.client.SetGroupPhoto(jid, nil)
570
+ } else {
571
+ // Ensure avatar is in JPEG format, and convert before setting if needed.
572
+ data, err := media.Convert(ctx, avatar, &media.Spec{MIME: media.TypeJPEG})
573
+ if err != nil {
574
+ return "", fmt.Errorf("Failed converting avatar to JPEG: %s", err)
575
+ }
576
+
577
+ return s.client.SetGroupPhoto(jid, data)
578
+ }
579
+ }
580
+
581
+ // SetGroupName updates the name of a WhatsApp group for the Group JID given.
582
+ func (s *Session) SetGroupName(resourceID, name string) error {
583
+ if s.client == nil || s.client.Store.ID == nil {
584
+ return fmt.Errorf("Cannot set group name for unauthenticated session")
585
+ }
586
+
587
+ jid, err := types.ParseJID(resourceID)
588
+ if err != nil {
589
+ return fmt.Errorf("Could not parse JID for group name change: %s", err)
590
+ }
591
+
592
+ return s.client.SetGroupName(jid, name)
593
+ }
594
+
595
+ // SetGroupName updates the topic of a WhatsApp group for the Group JID given.
596
+ func (s *Session) SetGroupTopic(resourceID, topic string) error {
597
+ if s.client == nil || s.client.Store.ID == nil {
598
+ return fmt.Errorf("Cannot set group topic for unauthenticated session")
599
+ }
600
+
601
+ jid, err := types.ParseJID(resourceID)
602
+ if err != nil {
603
+ return fmt.Errorf("Could not parse JID for group topic change: %s", err)
604
+ }
605
+
606
+ return s.client.SetGroupTopic(jid, "", "", topic)
607
+
608
+ }
609
+
610
+ // UpdateGroupParticipants processes changes to the given group's participants, including additions,
611
+ // removals, and changes to privileges. Participant JIDs given must be part of the authenticated
612
+ // session's roster at least, and must also be active group participants for other types of changes.
613
+ func (s *Session) UpdateGroupParticipants(resourceID string, participants []GroupParticipant) ([]GroupParticipant, error) {
614
+ if s.client == nil || s.client.Store.ID == nil {
615
+ return nil, fmt.Errorf("Cannot update group participants for unauthenticated session")
616
+ }
617
+
618
+ jid, err := types.ParseJID(resourceID)
619
+ if err != nil {
620
+ return nil, fmt.Errorf("Could not parse JID for group participant update: %s", err)
621
+ }
622
+
623
+ var changes = make(map[whatsmeow.ParticipantChange][]types.JID)
624
+ for _, p := range participants {
625
+ participantJID, err := types.ParseJID(p.JID)
626
+ if err != nil {
627
+ return nil, fmt.Errorf("Could not parse participant JID for update: %s", err)
628
+ }
629
+
630
+ if c, err := s.client.Store.Contacts.GetContact(participantJID); err != nil {
631
+ return nil, fmt.Errorf("Could not fetch contact for participant: %s", err)
632
+ } else if !c.Found {
633
+ return nil, fmt.Errorf("Cannot update group participant for contact '%s' not in roster", participantJID)
634
+ }
635
+
636
+ c := p.Action.toParticipantChange()
637
+ changes[c] = append(changes[c], participantJID)
638
+ }
639
+
640
+ var result []GroupParticipant
641
+ for change, participantJIDs := range changes {
642
+ participants, err := s.client.UpdateGroupParticipants(jid, participantJIDs, change)
643
+ if err != nil {
644
+ return nil, fmt.Errorf("Failed setting group affiliation: %s", err)
645
+ }
646
+ for i := range participants {
647
+ p := newGroupParticipant(participants[i])
648
+ if p.JID == "" {
649
+ continue
650
+ }
651
+ result = append(result, p)
652
+ }
653
+ }
654
+
655
+ return result, nil
656
+ }
657
+
658
+ // FindContact attempts to check for a registered contact on WhatsApp corresponding to the given
659
+ // phone number, returning a concrete instance if found; typically, only the contact JID is set. No
660
+ // error is returned if no contact was found, but any unexpected errors will otherwise be returned
661
+ // directly.
662
+ func (s *Session) FindContact(phone string) (Contact, error) {
663
+ if s.client == nil || s.client.Store.ID == nil {
664
+ return Contact{}, fmt.Errorf("Cannot find contact for unauthenticated session")
665
+ }
666
+
667
+ jid := types.NewJID(phone, DefaultUserServer)
668
+ if c, err := s.client.Store.Contacts.GetContact(jid); err == nil && c.Found {
669
+ if _, e := newContactEvent(jid, c); e != nil {
670
+ return e.Contact, nil
671
+ }
672
+ }
673
+
674
+ resp, err := s.client.IsOnWhatsApp([]string{phone})
675
+ if err != nil {
676
+ return Contact{}, fmt.Errorf("Failed looking up contact '%s': %s", phone, err)
677
+ } else if len(resp) != 1 {
678
+ return Contact{}, fmt.Errorf("Failed looking up contact '%s': invalid response", phone)
679
+ } else if !resp[0].IsIn || resp[0].JID.IsEmpty() {
680
+ return Contact{}, nil
681
+ }
682
+
683
+ return Contact{JID: resp[0].JID.ToNonAD().String()}, nil
684
+ }
685
+
686
+ // RequestMessageHistory sends and asynchronous request for message history related to the given
687
+ // resource (e.g. Contact or Group JID), ending at the oldest message given. Messages returned from
688
+ // history should then be handled as a `HistorySync` event of type `ON_DEMAND`, in the session-wide
689
+ // event handler. An error will be returned if requesting history fails for any reason.
690
+ func (s *Session) RequestMessageHistory(resourceID string, oldestMessage Message) error {
691
+ if s.client == nil || s.client.Store.ID == nil {
692
+ return fmt.Errorf("Cannot request history for unauthenticated session")
693
+ }
694
+
695
+ jid, err := types.ParseJID(resourceID)
696
+ if err != nil {
697
+ return fmt.Errorf("Could not parse JID for history request: %s", err)
698
+ }
699
+
700
+ info := &types.MessageInfo{
701
+ ID: oldestMessage.ID,
702
+ MessageSource: types.MessageSource{Chat: jid, IsFromMe: oldestMessage.IsCarbon},
703
+ Timestamp: time.Unix(oldestMessage.Timestamp, 0).UTC(),
704
+ }
705
+
706
+ req := s.client.BuildHistorySyncRequest(info, maxHistorySyncMessages)
707
+ _, err = s.client.SendMessage(context.Background(), s.device.JID().ToNonAD(), req, whatsmeow.SendRequestExtra{Peer: true})
708
+ if err != nil {
709
+ return fmt.Errorf("Failed to request history for %s: %s", resourceID, err)
710
+ }
711
+
712
+ return nil
713
+ }
714
+
715
+ // SetEventHandler assigns the given handler function for propagating internal events into the Python
716
+ // gateway. Note that the event handler function is not entirely safe to use directly, and all calls
717
+ // should instead be sent to the [Gateway] via its internal call channel.
718
+ func (s *Session) SetEventHandler(h HandleEventFunc) {
719
+ s.eventHandler = h
720
+ }
721
+
722
+ // PropagateEvent handles the given event kind and payload with the adapter event handler defined in
723
+ // [Session.SetEventHandler].
724
+ func (s *Session) propagateEvent(kind EventKind, payload *EventPayload) {
725
+ if s.eventHandler == nil {
726
+ s.gateway.logger.Errorf("Event handler not set when propagating event %d with payload %v", kind, payload)
727
+ return
728
+ } else if kind == EventUnknown {
729
+ return
730
+ }
731
+
732
+ // Send empty payload instead of a nil pointer, as Python has trouble handling the latter.
733
+ if payload == nil {
734
+ payload = &EventPayload{}
735
+ }
736
+
737
+ s.gateway.callChan <- func() { s.eventHandler(kind, payload) }
738
+ }
739
+
740
+ // HandleEvent processes the given incoming WhatsApp event, checking its concrete type and
741
+ // propagating it to the adapter event handler. Unknown or unhandled events are ignored, and any
742
+ // errors that occur during processing are logged.
743
+ func (s *Session) handleEvent(evt interface{}) {
744
+ s.gateway.logger.Debugf("Handling event '%T': %+v", evt, evt)
745
+
746
+ switch evt := evt.(type) {
747
+ case *events.AppStateSyncComplete:
748
+ if len(s.client.Store.PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock {
749
+ s.propagateEvent(EventConnect, &EventPayload{Connect: Connect{JID: s.device.JID().ToNonAD().String()}})
750
+ if err := s.client.SendPresence(types.PresenceAvailable); err != nil {
751
+ s.gateway.logger.Warnf("Failed to send available presence: %s", err)
752
+ }
753
+ }
754
+ case *events.ConnectFailure:
755
+ switch evt.Reason {
756
+ case events.ConnectFailureLoggedOut:
757
+ // These events are handled separately.
758
+ default:
759
+ s.gateway.logger.Errorf("Failed to connect: %s", evt.Message)
760
+ s.propagateEvent(EventConnect, &EventPayload{Connect: Connect{Error: evt.Message}})
761
+ }
762
+ case *events.Connected, *events.PushNameSetting:
763
+ if len(s.client.Store.PushName) == 0 {
764
+ return
765
+ }
766
+ s.propagateEvent(EventConnect, &EventPayload{Connect: Connect{JID: s.device.JID().ToNonAD().String()}})
767
+ if err := s.client.SendPresence(types.PresenceAvailable); err != nil {
768
+ s.gateway.logger.Warnf("Failed to send available presence: %s", err)
769
+ }
770
+ case *events.HistorySync:
771
+ switch evt.Data.GetSyncType() {
772
+ case waHistorySync.HistorySync_PUSH_NAME:
773
+ for _, n := range evt.Data.GetPushnames() {
774
+ jid, err := types.ParseJID(n.GetID())
775
+ if err != nil {
776
+ continue
777
+ }
778
+ s.propagateEvent(newContactEvent(jid, types.ContactInfo{FullName: n.GetPushname()}))
779
+ if err = s.client.SubscribePresence(jid); err != nil {
780
+ s.gateway.logger.Warnf("Failed to subscribe to presence for %s", jid)
781
+ }
782
+ }
783
+ case waHistorySync.HistorySync_INITIAL_BOOTSTRAP, waHistorySync.HistorySync_RECENT, waHistorySync.HistorySync_ON_DEMAND:
784
+ for _, c := range evt.Data.GetConversations() {
785
+ for _, msg := range c.GetMessages() {
786
+ s.propagateEvent(newEventFromHistory(s.client, msg.GetMessage()))
787
+ }
788
+ }
789
+ }
790
+ case *events.Message:
791
+ s.propagateEvent(newMessageEvent(s.client, evt))
792
+ case *events.Receipt:
793
+ s.propagateEvent(newReceiptEvent(evt))
794
+ case *events.Presence:
795
+ s.propagateEvent(newPresenceEvent(evt))
796
+ case *events.PushName:
797
+ s.propagateEvent(newContactEvent(evt.JID, types.ContactInfo{FullName: evt.NewPushName}))
798
+ case *events.JoinedGroup:
799
+ s.propagateEvent(EventGroup, &EventPayload{Group: newGroup(s.client, &evt.GroupInfo)})
800
+ case *events.GroupInfo:
801
+ s.propagateEvent(newGroupEvent(evt))
802
+ case *events.ChatPresence:
803
+ s.propagateEvent(newChatStateEvent(evt))
804
+ case *events.CallOffer:
805
+ s.propagateEvent(newCallEvent(CallIncoming, evt.BasicCallMeta))
806
+ case *events.CallTerminate:
807
+ s.propagateEvent(newCallEvent(callStateFromReason(evt.Reason), evt.BasicCallMeta))
808
+ case *events.LoggedOut:
809
+ s.client.Disconnect()
810
+ if err := s.client.Store.Delete(); err != nil {
811
+ s.gateway.logger.Warnf("Unable to delete local device state on logout: %s", err)
812
+ }
813
+ s.client = nil
814
+ s.propagateEvent(EventLoggedOut, nil)
815
+ case *events.PairSuccess:
816
+ if s.client.Store.ID == nil {
817
+ s.gateway.logger.Errorf("Pairing succeeded, but device ID is missing")
818
+ return
819
+ }
820
+ s.device.ID = s.client.Store.ID.String()
821
+ s.propagateEvent(EventPair, &EventPayload{PairDeviceID: s.device.ID})
822
+ if err := s.gateway.CleanupSession(LinkedDevice{ID: s.device.ID}); err != nil {
823
+ s.gateway.logger.Warnf("Failed to clean up devices after pair: %s", err)
824
+ }
825
+ case *events.KeepAliveTimeout:
826
+ if evt.ErrorCount > keepAliveFailureThreshold {
827
+ s.gateway.logger.Debugf("Forcing reconnection after keep-alive timeouts...")
828
+ go func() {
829
+ var interval = keepAliveMinRetryInterval
830
+ s.client.Disconnect()
831
+ for {
832
+ err := s.client.Connect()
833
+ if err == nil || err == whatsmeow.ErrAlreadyConnected {
834
+ break
835
+ }
836
+
837
+ s.gateway.logger.Errorf("Error reconnecting after keep-alive timeouts, retrying in %s: %s", interval, err)
838
+ time.Sleep(interval)
839
+
840
+ if interval > keepAliveMaxRetryInterval {
841
+ interval = keepAliveMaxRetryInterval
842
+ } else if interval < keepAliveMaxRetryInterval {
843
+ interval *= 2
844
+ }
845
+ }
846
+ }()
847
+ }
848
+ }
849
+ }
850
+
851
+ // PtrTo returns a pointer to the given value, and is used for convenience when converting between
852
+ // concrete and pointer values without assigning to a variable.
853
+ func ptrTo[T any](t T) *T {
854
+ return &t
855
+ }