slidge-whatsapp 0.2.2__cp311-cp311-manylinux_2_36_aarch64.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,745 @@
1
+ import asyncio
2
+ from datetime import datetime, timezone
3
+ from functools import wraps
4
+ from os.path import basename
5
+ from pathlib import Path
6
+ from re import search
7
+ from typing import Any, Optional, Union, cast
8
+
9
+ from aiohttp import ClientSession
10
+ from linkpreview import Link, LinkPreview
11
+ from slidge import BaseSession, FormField, GatewayUser, SearchResult, global_config
12
+ from slidge.contact.roster import ContactIsUser
13
+ from slidge.util import is_valid_phone_number
14
+ from slidge.util.types import (
15
+ LegacyAttachment,
16
+ Mention,
17
+ MessageReference,
18
+ PseudoPresenceShow,
19
+ ResourceDict,
20
+ )
21
+ from slixmpp.exceptions import XMPPError
22
+
23
+ from . import config
24
+ from .contact import Contact, Roster
25
+ from .gateway import Gateway
26
+ from .generated import go, whatsapp
27
+ from .group import MUC, Bookmarks, replace_xmpp_mentions
28
+
29
+ MESSAGE_PAIR_SUCCESS = (
30
+ "Pairing successful! You might need to repeat this process in the future if the"
31
+ " Linked Device is re-registered from your main device."
32
+ )
33
+
34
+ MESSAGE_LOGGED_OUT = (
35
+ "You have been logged out, please use the re-login adhoc command "
36
+ "and re-scan the QR code on your main device."
37
+ )
38
+
39
+ URL_SEARCH_REGEX = r"(?P<url>https?://[^\s]+)"
40
+ GEO_URI_SEARCH_REGEX = (
41
+ r"geo:(?P<lat>-?\d+(\.\d*)?),(?P<lon>-?\d+(\.\d*)?)(;u=(?P<acc>-?\d+(\.\d*)?))?"
42
+ )
43
+
44
+ VIDEO_PREVIEW_DOMAINS = (
45
+ "https://youtube.com/watch",
46
+ "https://m.youtube.com/watch",
47
+ "https://youtu.be",
48
+ )
49
+
50
+
51
+ Recipient = Union[Contact, MUC]
52
+
53
+
54
+ def ignore_contact_is_user(func):
55
+ @wraps(func)
56
+ async def wrapped(self, *a, **k):
57
+ try:
58
+ return await func(self, *a, **k)
59
+ except ContactIsUser as e:
60
+ self.log.debug("A wild ContactIsUser has been raised!", exc_info=e)
61
+
62
+ return wrapped
63
+
64
+
65
+ class Session(BaseSession[str, Recipient]):
66
+ xmpp: Gateway
67
+ contacts: Roster
68
+ bookmarks: Bookmarks
69
+
70
+ def __init__(self, user: GatewayUser):
71
+ super().__init__(user)
72
+ self.migrate()
73
+ try:
74
+ device = whatsapp.LinkedDevice(ID=self.user.legacy_module_data["device_id"])
75
+ except KeyError:
76
+ device = whatsapp.LinkedDevice()
77
+ self.__presence_status: str = ""
78
+ self.user_phone: Optional[str] = None
79
+ self.whatsapp_participants = dict[str, list[whatsapp.GroupParticipant]]()
80
+ self.whatsapp = self.xmpp.whatsapp.NewSession(device)
81
+ self.__handle_event = make_sync(self.handle_event, self.xmpp.loop)
82
+ self.whatsapp.SetEventHandler(self.__handle_event)
83
+ self.__reset_connected()
84
+
85
+ def migrate(self):
86
+ user_shelf_path = (
87
+ global_config.HOME_DIR / "whatsapp" / (self.user_jid.bare + ".shelf")
88
+ )
89
+ if not user_shelf_path.exists():
90
+ return
91
+ import shelve
92
+
93
+ with shelve.open(str(user_shelf_path)) as shelf:
94
+ try:
95
+ device_id = shelf["device_id"]
96
+ except KeyError:
97
+ pass
98
+ else:
99
+ self.log.info(
100
+ "Migrated data from %s to the slidge main DB", user_shelf_path
101
+ )
102
+ self.legacy_module_data_set({"device_id": device_id})
103
+ user_shelf_path.unlink()
104
+
105
+ async def login(self):
106
+ """
107
+ Initiate login process and connect session to WhatsApp. Depending on existing state, login
108
+ might either return having initiated the Linked Device registration process in the background,
109
+ or will re-connect to a previously existing Linked Device session.
110
+ """
111
+ self.__reset_connected()
112
+ self.whatsapp.Login()
113
+ return await self.__connected
114
+
115
+ async def logout(self):
116
+ """
117
+ Disconnect the active WhatsApp session. This will not remove any local or remote state, and
118
+ will thus allow previously authenticated sessions to re-authenticate without needing to pair.
119
+ """
120
+ self.whatsapp.Disconnect()
121
+ self.logged = False
122
+
123
+ @ignore_contact_is_user
124
+ async def handle_event(self, event, ptr):
125
+ """
126
+ Handle incoming event, as propagated by the WhatsApp adapter. Typically, events carry all
127
+ state required for processing by the Gateway itself, and will do minimal processing themselves.
128
+ """
129
+ data = whatsapp.EventPayload(handle=ptr)
130
+ if event == whatsapp.EventQRCode:
131
+ self.send_gateway_status("QR Scan Needed", show="dnd")
132
+ await self.send_qr(data.QRCode)
133
+ elif event == whatsapp.EventPair:
134
+ self.send_gateway_message(MESSAGE_PAIR_SUCCESS)
135
+ self.legacy_module_data_set({"device_id": data.PairDeviceID})
136
+ elif event == whatsapp.EventConnect:
137
+ # On re-pair, Session.login() is not called by slidge core, so the status message is
138
+ # not updated.
139
+ if self.__connected.done():
140
+ if data.Connect.Error != "":
141
+ self.send_gateway_status(
142
+ self.__get_connected_status_message(), show="chat"
143
+ )
144
+ else:
145
+ self.send_gateway_status("Connection error", show="dnd")
146
+ self.send_gateway_message(data.Connect.Error)
147
+ elif data.Connect.Error != "":
148
+ self.xmpp.loop.call_soon_threadsafe(
149
+ self.__connected.set_exception,
150
+ XMPPError("internal-server-error", data.Connect.Error),
151
+ )
152
+ else:
153
+ self.contacts.user_legacy_id = data.Connect.JID
154
+ self.user_phone = "+" + data.Connect.JID.split("@")[0]
155
+ self.xmpp.loop.call_soon_threadsafe(
156
+ self.__connected.set_result, self.__get_connected_status_message()
157
+ )
158
+ elif event == whatsapp.EventLoggedOut:
159
+ self.logged = False
160
+ self.send_gateway_message(MESSAGE_LOGGED_OUT)
161
+ self.send_gateway_status("Logged out", show="away")
162
+ elif event == whatsapp.EventContact:
163
+ await self.contacts.add_whatsapp_contact(data.Contact)
164
+ elif event == whatsapp.EventGroup:
165
+ await self.bookmarks.add_whatsapp_group(data.Group)
166
+ elif event == whatsapp.EventPresence:
167
+ contact = await self.contacts.by_legacy_id(data.Presence.JID)
168
+ await contact.update_presence(data.Presence.Kind, data.Presence.LastSeen)
169
+ elif event == whatsapp.EventChatState:
170
+ await self.handle_chat_state(data.ChatState)
171
+ elif event == whatsapp.EventReceipt:
172
+ await self.handle_receipt(data.Receipt)
173
+ elif event == whatsapp.EventCall:
174
+ await self.handle_call(data.Call)
175
+ elif event == whatsapp.EventMessage:
176
+ await self.handle_message(data.Message)
177
+
178
+ async def handle_chat_state(self, state: whatsapp.ChatState):
179
+ contact = await self.__get_contact_or_participant(state.JID, state.GroupJID)
180
+ if state.Kind == whatsapp.ChatStateComposing:
181
+ contact.composing()
182
+ elif state.Kind == whatsapp.ChatStatePaused:
183
+ contact.paused()
184
+
185
+ async def handle_receipt(self, receipt: whatsapp.Receipt):
186
+ """
187
+ Handle incoming delivered/read receipt, as propagated by the WhatsApp adapter.
188
+ """
189
+ contact = await self.__get_contact_or_participant(receipt.JID, receipt.GroupJID)
190
+ for message_id in receipt.MessageIDs:
191
+ if receipt.Kind == whatsapp.ReceiptDelivered:
192
+ contact.received(message_id)
193
+ elif receipt.Kind == whatsapp.ReceiptRead:
194
+ contact.displayed(legacy_msg_id=message_id, carbon=receipt.IsCarbon)
195
+
196
+ async def handle_call(self, call: whatsapp.Call):
197
+ contact = await self.contacts.by_legacy_id(call.JID)
198
+ text = f"from {contact.name or 'tel:' + str(contact.jid.local)} (xmpp:{contact.jid.bare})"
199
+ if call.State == whatsapp.CallIncoming:
200
+ text = "Incoming call " + text
201
+ elif call.State == whatsapp.CallMissed:
202
+ text = "Missed call " + text
203
+ else:
204
+ text = "Call " + text
205
+ if call.Timestamp > 0:
206
+ call_at = datetime.fromtimestamp(call.Timestamp, tz=timezone.utc)
207
+ text = text + f" at {call_at}"
208
+ self.send_gateway_message(text)
209
+
210
+ async def handle_message(self, message: whatsapp.Message):
211
+ """
212
+ Handle incoming message, as propagated by the WhatsApp adapter. Messages can be one of many
213
+ types, including plain-text messages, media messages, reactions, etc., and may also include
214
+ other aspects such as references to other messages for the purposes of quoting or correction.
215
+ """
216
+ contact = await self.__get_contact_or_participant(message.JID, message.GroupJID)
217
+ muc = getattr(contact, "muc", None)
218
+ reply_to = await self.__get_reply_to(message, muc)
219
+ message_timestamp = (
220
+ datetime.fromtimestamp(message.Timestamp, tz=timezone.utc)
221
+ if message.Timestamp > 0
222
+ else None
223
+ )
224
+ if message.Kind == whatsapp.MessagePlain:
225
+ body = await self.__get_body(message, muc)
226
+ contact.send_text(
227
+ body=body,
228
+ legacy_msg_id=message.ID,
229
+ when=message_timestamp,
230
+ reply_to=reply_to,
231
+ carbon=message.IsCarbon,
232
+ )
233
+ elif message.Kind == whatsapp.MessageAttachment:
234
+ attachments = await Attachment.convert_list(message.Attachments, muc)
235
+ await contact.send_files(
236
+ attachments=attachments,
237
+ legacy_msg_id=message.ID,
238
+ reply_to=reply_to,
239
+ when=message_timestamp,
240
+ carbon=message.IsCarbon,
241
+ )
242
+ for attachment in attachments:
243
+ if global_config.NO_UPLOAD_METHOD != "symlink":
244
+ self.log.debug("Removing '%s' from disk", attachment.path)
245
+ if attachment.path is None:
246
+ continue
247
+ Path(attachment.path).unlink(missing_ok=True)
248
+ elif message.Kind == whatsapp.MessageEdit:
249
+ contact.correct(
250
+ legacy_msg_id=message.ID,
251
+ new_text=message.Body,
252
+ when=message_timestamp,
253
+ reply_to=reply_to,
254
+ carbon=message.IsCarbon,
255
+ )
256
+ elif message.Kind == whatsapp.MessageRevoke:
257
+ if muc is None or message.OriginJID == message.JID:
258
+ contact.retract(legacy_msg_id=message.ID, carbon=message.IsCarbon)
259
+ else:
260
+ contact.moderate(legacy_msg_id=message.ID)
261
+ elif message.Kind == whatsapp.MessageReaction:
262
+ emojis = [message.Body] if message.Body else []
263
+ contact.react(
264
+ legacy_msg_id=message.ID, emojis=emojis, carbon=message.IsCarbon
265
+ )
266
+ for receipt in message.Receipts:
267
+ await self.handle_receipt(receipt)
268
+ for reaction in message.Reactions:
269
+ await self.handle_message(reaction)
270
+
271
+ async def on_text(
272
+ self,
273
+ chat: Recipient,
274
+ text: str,
275
+ *,
276
+ reply_to_msg_id: Optional[str] = None,
277
+ reply_to_fallback_text: Optional[str] = None,
278
+ reply_to=None,
279
+ mentions: Optional[list[Mention]] = None,
280
+ **_,
281
+ ):
282
+ """
283
+ Send outgoing plain-text message to given WhatsApp contact.
284
+ """
285
+ message_id = self.whatsapp.GenerateMessageID()
286
+ message_preview = await self.__get_preview(text) or whatsapp.Preview()
287
+ message_location = await self.__get_location(text) or whatsapp.Location()
288
+ message = whatsapp.Message(
289
+ ID=message_id,
290
+ JID=chat.legacy_id,
291
+ Body=replace_xmpp_mentions(text, mentions) if mentions else text,
292
+ Preview=message_preview,
293
+ Location=message_location,
294
+ MentionJIDs=go.Slice_string([m.contact.legacy_id for m in mentions or []]),
295
+ )
296
+ set_reply_to(chat, message, reply_to_msg_id, reply_to_fallback_text, reply_to)
297
+ self.whatsapp.SendMessage(message)
298
+ return message_id
299
+
300
+ async def on_file(
301
+ self,
302
+ chat: Recipient,
303
+ url: str,
304
+ http_response,
305
+ reply_to_msg_id: Optional[str] = None,
306
+ reply_to_fallback_text: Optional[str] = None,
307
+ reply_to=None,
308
+ **_,
309
+ ):
310
+ """
311
+ Send outgoing media message (i.e. audio, image, document) to given WhatsApp contact.
312
+ """
313
+ data = await get_url_bytes(self.http, url)
314
+ if not data:
315
+ raise XMPPError(
316
+ "internal-server-error",
317
+ "Unable to retrieve file from XMPP server, try again",
318
+ )
319
+ message_id = self.whatsapp.GenerateMessageID()
320
+ message_attachment = whatsapp.Attachment(
321
+ MIME=http_response.content_type,
322
+ Filename=basename(url),
323
+ Data=go.Slice_byte.from_bytes(data),
324
+ )
325
+ message = whatsapp.Message(
326
+ Kind=whatsapp.MessageAttachment,
327
+ ID=message_id,
328
+ JID=chat.legacy_id,
329
+ ReplyID=reply_to_msg_id if reply_to_msg_id else "",
330
+ Attachments=whatsapp.Slice_whatsapp_Attachment([message_attachment]),
331
+ )
332
+ set_reply_to(chat, message, reply_to_msg_id, reply_to_fallback_text, reply_to)
333
+ self.whatsapp.SendMessage(message)
334
+ return message_id
335
+
336
+ async def on_presence(
337
+ self,
338
+ resource: str,
339
+ show: PseudoPresenceShow,
340
+ status: str,
341
+ resources: dict[str, ResourceDict],
342
+ merged_resource: Optional[ResourceDict],
343
+ ):
344
+ """
345
+ Send outgoing availability status (i.e. presence) based on combined status of all connected
346
+ XMPP clients.
347
+ """
348
+ if not merged_resource:
349
+ self.whatsapp.SendPresence(whatsapp.PresenceUnavailable, "")
350
+ else:
351
+ presence = (
352
+ whatsapp.PresenceAvailable
353
+ if merged_resource["show"] in ["chat", ""]
354
+ else whatsapp.PresenceUnavailable
355
+ )
356
+ status = (
357
+ merged_resource["status"]
358
+ if self.__presence_status != merged_resource["status"]
359
+ else ""
360
+ )
361
+ if status:
362
+ self.__presence_status = status
363
+ self.whatsapp.SendPresence(presence, status)
364
+
365
+ async def on_active(self, c: Recipient, thread=None):
366
+ """
367
+ WhatsApp has no equivalent to the "active" chat state, so calls to this function are no-ops.
368
+ """
369
+ pass
370
+
371
+ async def on_inactive(self, c: Recipient, thread=None):
372
+ """
373
+ WhatsApp has no equivalent to the "inactive" chat state, so calls to this function are no-ops.
374
+ """
375
+ pass
376
+
377
+ async def on_composing(self, c: Recipient, thread=None):
378
+ """
379
+ Send "composing" chat state to given WhatsApp contact, signifying that a message is currently
380
+ being composed.
381
+ """
382
+ state = whatsapp.ChatState(JID=c.legacy_id, Kind=whatsapp.ChatStateComposing)
383
+ self.whatsapp.SendChatState(state)
384
+
385
+ async def on_paused(self, c: Recipient, thread=None):
386
+ """
387
+ Send "paused" chat state to given WhatsApp contact, signifying that an (unsent) message is no
388
+ longer being composed.
389
+ """
390
+ state = whatsapp.ChatState(JID=c.legacy_id, Kind=whatsapp.ChatStatePaused)
391
+ self.whatsapp.SendChatState(state)
392
+
393
+ async def on_displayed(self, c: Recipient, legacy_msg_id: str, thread=None):
394
+ """
395
+ Send "read" receipt, signifying that the WhatsApp message sent has been displayed on the XMPP
396
+ client.
397
+ """
398
+ receipt = whatsapp.Receipt(
399
+ MessageIDs=go.Slice_string([legacy_msg_id]),
400
+ JID=(
401
+ c.get_message_sender(legacy_msg_id)
402
+ if isinstance(c, MUC)
403
+ else c.legacy_id
404
+ ),
405
+ GroupJID=c.legacy_id if c.is_group else "",
406
+ )
407
+ self.whatsapp.SendReceipt(receipt)
408
+
409
+ async def on_react(
410
+ self, c: Recipient, legacy_msg_id: str, emojis: list[str], thread=None
411
+ ):
412
+ """
413
+ Send or remove emoji reaction to existing WhatsApp message.
414
+ Slidge core makes sure that the emojis parameter is always empty or a
415
+ *single* emoji.
416
+ """
417
+ is_carbon = self.message_is_carbon(c, legacy_msg_id)
418
+ message_sender_id = (
419
+ c.get_message_sender(legacy_msg_id)
420
+ if not is_carbon and isinstance(c, MUC)
421
+ else ""
422
+ )
423
+ message = whatsapp.Message(
424
+ Kind=whatsapp.MessageReaction,
425
+ ID=legacy_msg_id,
426
+ JID=c.legacy_id,
427
+ OriginJID=message_sender_id,
428
+ Body=emojis[0] if emojis else "",
429
+ IsCarbon=is_carbon,
430
+ )
431
+ self.whatsapp.SendMessage(message)
432
+
433
+ async def on_retract(self, c: Recipient, legacy_msg_id: str, thread=None):
434
+ """
435
+ Request deletion (aka retraction) for a given WhatsApp message.
436
+ """
437
+ message = whatsapp.Message(
438
+ Kind=whatsapp.MessageRevoke, ID=legacy_msg_id, JID=c.legacy_id
439
+ )
440
+ self.whatsapp.SendMessage(message)
441
+
442
+ async def on_moderate(
443
+ self,
444
+ muc: MUC, # type:ignore
445
+ legacy_msg_id: str,
446
+ reason: Optional[str],
447
+ ):
448
+ message = whatsapp.Message(
449
+ Kind=whatsapp.MessageRevoke,
450
+ ID=legacy_msg_id,
451
+ JID=muc.legacy_id,
452
+ OriginJID=muc.get_message_sender(legacy_msg_id),
453
+ )
454
+ self.whatsapp.SendMessage(message)
455
+ # Apparently, no revoke event is received by whatsmeow after sending
456
+ # the revoke message, so we need to "echo" it here.
457
+ part = await muc.get_user_participant()
458
+ part.moderate(legacy_msg_id)
459
+
460
+ async def on_correct(
461
+ self,
462
+ c: Recipient,
463
+ text: str,
464
+ legacy_msg_id: str,
465
+ thread=None,
466
+ link_previews=(),
467
+ mentions=None,
468
+ ):
469
+ """
470
+ Request correction (aka editing) for a given WhatsApp message.
471
+ """
472
+ message = whatsapp.Message(
473
+ Kind=whatsapp.MessageEdit, ID=legacy_msg_id, JID=c.legacy_id, Body=text
474
+ )
475
+ self.whatsapp.SendMessage(message)
476
+
477
+ async def on_avatar(
478
+ self,
479
+ bytes_: Optional[bytes],
480
+ hash_: Optional[str],
481
+ type_: Optional[str],
482
+ width: Optional[int],
483
+ height: Optional[int],
484
+ ) -> None:
485
+ """
486
+ Update profile picture in WhatsApp for corresponding avatar change in XMPP.
487
+ """
488
+ self.whatsapp.SetAvatar(
489
+ "", go.Slice_byte.from_bytes(bytes_) if bytes_ else go.Slice_byte()
490
+ )
491
+
492
+ async def on_create_group(
493
+ self,
494
+ name: str,
495
+ contacts: list[Contact], # type:ignore
496
+ ):
497
+ """
498
+ Creates a WhatsApp group for the given human-readable name and participant list.
499
+ """
500
+ group = self.whatsapp.CreateGroup(
501
+ name, go.Slice_string([c.legacy_id for c in contacts])
502
+ )
503
+ muc = await self.bookmarks.by_legacy_id(group.JID)
504
+ return muc.legacy_id
505
+
506
+ async def on_leave_group(self, legacy_muc_id: str): # type:ignore
507
+ """
508
+ Removes own user from given WhatsApp group.
509
+ """
510
+ self.whatsapp.LeaveGroup(legacy_muc_id)
511
+
512
+ async def on_search(self, form_values: dict[str, str]):
513
+ """
514
+ Searches for, and automatically adds, WhatsApp contact based on phone number. Phone numbers
515
+ not registered on WhatsApp will be ignored with no error.
516
+ """
517
+ phone = form_values.get("phone")
518
+ if not is_valid_phone_number(phone):
519
+ raise ValueError("Not a valid phone number", phone)
520
+
521
+ data = self.whatsapp.FindContact(phone)
522
+ if not data.JID:
523
+ return
524
+
525
+ await self.contacts.add_whatsapp_contact(data)
526
+ contact = await self.contacts.by_legacy_id(data.JID)
527
+
528
+ return SearchResult(
529
+ fields=[FormField("phone"), FormField("jid", type="jid-single")],
530
+ items=[{"phone": cast(str, phone), "jid": contact.jid.bare}],
531
+ )
532
+
533
+ def message_is_carbon(self, c: Recipient, legacy_msg_id: str):
534
+ stored: Any
535
+ if c.is_group:
536
+ assert isinstance(c, MUC)
537
+ assert c.pk is not None
538
+ stored = self.xmpp.store.sent.get_group_xmpp_id(c.pk, legacy_msg_id)
539
+ else:
540
+ stored = self.xmpp.store.sent.get_xmpp_id(self.user_pk, legacy_msg_id)
541
+ return stored is not None
542
+
543
+ def __reset_connected(self):
544
+ if hasattr(self, "_connected") and not self.__connected.done():
545
+ self.xmpp.loop.call_soon_threadsafe(self.__connected.cancel)
546
+ self.__connected: asyncio.Future[str] = self.xmpp.loop.create_future()
547
+
548
+ def __get_connected_status_message(self):
549
+ return f"Connected as {self.user_phone}"
550
+
551
+ async def __get_body(
552
+ self, message: whatsapp.Message, muc: Optional["MUC"] = None
553
+ ) -> str:
554
+ body = message.Body
555
+ if muc:
556
+ body = await muc.replace_mentions(body)
557
+ if message.Location.Latitude != 0 or message.Location.Longitude != 0:
558
+ body = "geo:%f,%f" % (message.Location.Latitude, message.Location.Longitude)
559
+ if message.Location.Accuracy > 0:
560
+ body = body + ";u=%d" % message.Location.Accuracy
561
+ if message.IsForwarded:
562
+ body = "↱ Forwarded message:\n " + add_quote_prefix(body)
563
+ return body
564
+
565
+ async def __get_reply_to(
566
+ self, message: whatsapp.Message, muc: Optional["MUC"] = None
567
+ ) -> Optional[MessageReference]:
568
+ if not message.ReplyID:
569
+ return None
570
+ reply_to = MessageReference(
571
+ legacy_id=message.ReplyID,
572
+ body=(
573
+ message.ReplyBody
574
+ if muc is None
575
+ else await muc.replace_mentions(message.ReplyBody)
576
+ ),
577
+ )
578
+ if message.OriginJID == self.contacts.user_legacy_id:
579
+ reply_to.author = "user"
580
+ else:
581
+ reply_to.author = await self.__get_contact_or_participant(
582
+ message.OriginJID, message.GroupJID
583
+ )
584
+ return reply_to
585
+
586
+ async def __get_preview(self, text: str) -> Optional[whatsapp.Preview]:
587
+ if not config.ENABLE_LINK_PREVIEWS:
588
+ return None
589
+ match = search(URL_SEARCH_REGEX, text)
590
+ if not match:
591
+ return None
592
+ url = match.group("url")
593
+ async with self.http.get(url) as resp:
594
+ if resp.status != 200:
595
+ self.log.debug(
596
+ "Could not generate a preview for %s because response status was %s",
597
+ url,
598
+ resp.status,
599
+ )
600
+ return None
601
+ if resp.content_type != "text/html":
602
+ self.log.debug(
603
+ "Could not generate a preview for %s because content type is %s",
604
+ url,
605
+ resp.content_type,
606
+ )
607
+ return None
608
+ try:
609
+ html = await resp.text()
610
+ except Exception as e:
611
+ self.log.debug("Could not generate a preview for %s", url, exc_info=e)
612
+ return None
613
+ preview = LinkPreview(Link(url, html))
614
+ if not preview.title:
615
+ return None
616
+ try:
617
+ thumbnail = (
618
+ await get_url_bytes(self.http, preview.image)
619
+ if preview.image
620
+ else None
621
+ )
622
+ kind = (
623
+ whatsapp.PreviewVideo
624
+ if url.startswith(VIDEO_PREVIEW_DOMAINS)
625
+ else whatsapp.PreviewPlain
626
+ )
627
+ return whatsapp.Preview(
628
+ Kind=kind,
629
+ Title=preview.title,
630
+ Description=preview.description or "",
631
+ URL=url,
632
+ Thumbnail=(
633
+ go.Slice_byte.from_bytes(thumbnail)
634
+ if thumbnail
635
+ else go.Slice_byte()
636
+ ),
637
+ )
638
+ except Exception as e:
639
+ self.log.debug("Could not generate a preview for %s", url, exc_info=e)
640
+ return None
641
+
642
+ async def __get_location(self, text: str) -> Optional[whatsapp.Location]:
643
+ match = search(GEO_URI_SEARCH_REGEX, text)
644
+ if not match:
645
+ return None
646
+ latitude = match.group("lat")
647
+ longitude = match.group("lon")
648
+ if latitude == "" or longitude == "":
649
+ return None
650
+ return whatsapp.Location(
651
+ Latitude=float(latitude),
652
+ Longitude=float(longitude),
653
+ Accuracy=int(match.group("acc") or 0),
654
+ )
655
+
656
+ async def __get_contact_or_participant(
657
+ self, legacy_contact_id: str, legacy_group_jid: str
658
+ ):
659
+ """
660
+ Return either a Contact or a Participant instance for the given contact and group JIDs.
661
+ """
662
+ if legacy_group_jid:
663
+ muc = await self.bookmarks.by_legacy_id(legacy_group_jid)
664
+ return await muc.get_participant_by_legacy_id(legacy_contact_id)
665
+ else:
666
+ return await self.contacts.by_legacy_id(legacy_contact_id)
667
+
668
+
669
+ class Attachment(LegacyAttachment):
670
+ @staticmethod
671
+ async def convert_list(
672
+ attachments: list, muc: Optional["MUC"] = None
673
+ ) -> list["Attachment"]:
674
+ return [await Attachment.convert(attachment, muc) for attachment in attachments]
675
+
676
+ @staticmethod
677
+ async def convert(
678
+ wa_attachment: whatsapp.Attachment, muc: Optional["MUC"] = None
679
+ ) -> "Attachment":
680
+ return Attachment(
681
+ content_type=wa_attachment.MIME,
682
+ data=bytes(wa_attachment.Data),
683
+ caption=(
684
+ wa_attachment.Caption
685
+ if muc is None
686
+ else await muc.replace_mentions(wa_attachment.Caption)
687
+ ),
688
+ name=wa_attachment.Filename,
689
+ )
690
+
691
+
692
+ def add_quote_prefix(text: str):
693
+ """
694
+ Return multi-line text with leading quote marks (i.e. the ">" character).
695
+ """
696
+ return "\n".join(("> " + x).strip() for x in text.split("\n")).strip()
697
+
698
+
699
+ def strip_quote_prefix(text: str):
700
+ """
701
+ Return multi-line text without leading quote marks (i.e. the ">" character).
702
+ """
703
+ return "\n".join(x.lstrip(">").strip() for x in text.split("\n")).strip()
704
+
705
+
706
+ def set_reply_to(
707
+ chat: Recipient,
708
+ message: whatsapp.Message,
709
+ reply_to_msg_id: Optional[str] = None,
710
+ reply_to_fallback_text: Optional[str] = None,
711
+ reply_to=None,
712
+ ):
713
+ if reply_to_msg_id:
714
+ message.ReplyID = reply_to_msg_id
715
+ if reply_to:
716
+ message.OriginJID = (
717
+ reply_to.contact.legacy_id if chat.is_group else chat.legacy_id
718
+ )
719
+ if reply_to_fallback_text:
720
+ message.ReplyBody = strip_quote_prefix(reply_to_fallback_text)
721
+ message.Body = message.Body.lstrip()
722
+ return message
723
+
724
+
725
+ async def get_url_bytes(client: ClientSession, url: str) -> Optional[bytes]:
726
+ async with client.get(url) as resp:
727
+ if resp.status == 200:
728
+ return await resp.read()
729
+ return None
730
+
731
+
732
+ def make_sync(func, loop):
733
+ """
734
+ Wrap async function in synchronous operation, running against the given loop in thread-safe mode.
735
+ """
736
+
737
+ @wraps(func)
738
+ def wrapper(*args, **kwargs):
739
+ result = func(*args, **kwargs)
740
+ if asyncio.iscoroutine(result):
741
+ future = asyncio.run_coroutine_threadsafe(result, loop)
742
+ return future.result()
743
+ return result
744
+
745
+ return wrapper