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