slidge 0.1.0__py3-none-any.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.
- slidge/__init__.py +61 -0
- slidge/__main__.py +192 -0
- slidge/command/__init__.py +28 -0
- slidge/command/adhoc.py +258 -0
- slidge/command/admin.py +193 -0
- slidge/command/base.py +441 -0
- slidge/command/categories.py +3 -0
- slidge/command/chat_command.py +288 -0
- slidge/command/register.py +179 -0
- slidge/command/user.py +250 -0
- slidge/contact/__init__.py +8 -0
- slidge/contact/contact.py +452 -0
- slidge/contact/roster.py +192 -0
- slidge/core/__init__.py +3 -0
- slidge/core/cache.py +183 -0
- slidge/core/config.py +209 -0
- slidge/core/gateway/__init__.py +3 -0
- slidge/core/gateway/base.py +892 -0
- slidge/core/gateway/caps.py +63 -0
- slidge/core/gateway/delivery_receipt.py +52 -0
- slidge/core/gateway/disco.py +80 -0
- slidge/core/gateway/mam.py +75 -0
- slidge/core/gateway/muc_admin.py +35 -0
- slidge/core/gateway/ping.py +66 -0
- slidge/core/gateway/presence.py +95 -0
- slidge/core/gateway/registration.py +53 -0
- slidge/core/gateway/search.py +102 -0
- slidge/core/gateway/session_dispatcher.py +757 -0
- slidge/core/gateway/vcard_temp.py +130 -0
- slidge/core/mixins/__init__.py +19 -0
- slidge/core/mixins/attachment.py +506 -0
- slidge/core/mixins/avatar.py +167 -0
- slidge/core/mixins/base.py +31 -0
- slidge/core/mixins/disco.py +130 -0
- slidge/core/mixins/lock.py +31 -0
- slidge/core/mixins/message.py +398 -0
- slidge/core/mixins/message_maker.py +154 -0
- slidge/core/mixins/presence.py +217 -0
- slidge/core/mixins/recipient.py +43 -0
- slidge/core/pubsub.py +525 -0
- slidge/core/session.py +752 -0
- slidge/group/__init__.py +10 -0
- slidge/group/archive.py +125 -0
- slidge/group/bookmarks.py +163 -0
- slidge/group/participant.py +440 -0
- slidge/group/room.py +1095 -0
- slidge/migration.py +18 -0
- slidge/py.typed +0 -0
- slidge/slixfix/__init__.py +68 -0
- slidge/slixfix/link_preview/__init__.py +10 -0
- slidge/slixfix/link_preview/link_preview.py +17 -0
- slidge/slixfix/link_preview/stanza.py +99 -0
- slidge/slixfix/roster.py +60 -0
- slidge/slixfix/xep_0077/__init__.py +10 -0
- slidge/slixfix/xep_0077/register.py +289 -0
- slidge/slixfix/xep_0077/stanza.py +104 -0
- slidge/slixfix/xep_0100/__init__.py +5 -0
- slidge/slixfix/xep_0100/gateway.py +121 -0
- slidge/slixfix/xep_0100/stanza.py +9 -0
- slidge/slixfix/xep_0153/__init__.py +10 -0
- slidge/slixfix/xep_0153/stanza.py +25 -0
- slidge/slixfix/xep_0153/vcard_avatar.py +23 -0
- slidge/slixfix/xep_0264/__init__.py +5 -0
- slidge/slixfix/xep_0264/stanza.py +36 -0
- slidge/slixfix/xep_0264/thumbnail.py +23 -0
- slidge/slixfix/xep_0292/__init__.py +5 -0
- slidge/slixfix/xep_0292/vcard4.py +100 -0
- slidge/slixfix/xep_0313/__init__.py +12 -0
- slidge/slixfix/xep_0313/mam.py +262 -0
- slidge/slixfix/xep_0313/stanza.py +359 -0
- slidge/slixfix/xep_0317/__init__.py +5 -0
- slidge/slixfix/xep_0317/hats.py +17 -0
- slidge/slixfix/xep_0317/stanza.py +28 -0
- slidge/slixfix/xep_0356_old/__init__.py +7 -0
- slidge/slixfix/xep_0356_old/privilege.py +167 -0
- slidge/slixfix/xep_0356_old/stanza.py +44 -0
- slidge/slixfix/xep_0424/__init__.py +9 -0
- slidge/slixfix/xep_0424/retraction.py +77 -0
- slidge/slixfix/xep_0424/stanza.py +28 -0
- slidge/slixfix/xep_0490/__init__.py +8 -0
- slidge/slixfix/xep_0490/mds.py +47 -0
- slidge/slixfix/xep_0490/stanza.py +17 -0
- slidge/util/__init__.py +15 -0
- slidge/util/archive_msg.py +61 -0
- slidge/util/conf.py +206 -0
- slidge/util/db.py +229 -0
- slidge/util/schema.sql +126 -0
- slidge/util/sql.py +508 -0
- slidge/util/test.py +295 -0
- slidge/util/types.py +180 -0
- slidge/util/util.py +295 -0
- slidge-0.1.0.dist-info/LICENSE +661 -0
- slidge-0.1.0.dist-info/METADATA +109 -0
- slidge-0.1.0.dist-info/RECORD +96 -0
- slidge-0.1.0.dist-info/WHEEL +4 -0
- slidge-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,452 @@
|
|
1
|
+
import datetime
|
2
|
+
import logging
|
3
|
+
import warnings
|
4
|
+
from datetime import date
|
5
|
+
from typing import TYPE_CHECKING, Generic, Iterable, Optional, Union
|
6
|
+
|
7
|
+
from slixmpp import JID, Message, Presence
|
8
|
+
from slixmpp.exceptions import IqError
|
9
|
+
from slixmpp.plugins.xep_0292.stanza import VCard4
|
10
|
+
from slixmpp.types import MessageTypes
|
11
|
+
|
12
|
+
from ..core import config
|
13
|
+
from ..core.mixins import FullCarbonMixin
|
14
|
+
from ..core.mixins.avatar import AvatarMixin
|
15
|
+
from ..core.mixins.disco import ContactAccountDiscoMixin
|
16
|
+
from ..core.mixins.recipient import ReactionRecipientMixin, ThreadRecipientMixin
|
17
|
+
from ..util import SubclassableOnce
|
18
|
+
from ..util.types import LegacyUserIdType, MessageOrPresenceTypeVar
|
19
|
+
|
20
|
+
if TYPE_CHECKING:
|
21
|
+
from ..core.session import BaseSession
|
22
|
+
from ..group.participant import LegacyParticipant
|
23
|
+
|
24
|
+
|
25
|
+
class LegacyContact(
|
26
|
+
Generic[LegacyUserIdType],
|
27
|
+
AvatarMixin,
|
28
|
+
ContactAccountDiscoMixin,
|
29
|
+
FullCarbonMixin,
|
30
|
+
ReactionRecipientMixin,
|
31
|
+
ThreadRecipientMixin,
|
32
|
+
metaclass=SubclassableOnce,
|
33
|
+
):
|
34
|
+
"""
|
35
|
+
This class centralizes actions in relation to a specific legacy contact.
|
36
|
+
|
37
|
+
You shouldn't create instances of contacts manually, but rather rely on
|
38
|
+
:meth:`.LegacyRoster.by_legacy_id` to ensure that contact instances are
|
39
|
+
singletons. The :class:`.LegacyRoster` instance of a session is accessible
|
40
|
+
through the :attr:`.BaseSession.contacts` attribute.
|
41
|
+
|
42
|
+
Typically, your plugin should have methods hook to the legacy events and
|
43
|
+
call appropriate methods here to transmit the "legacy action" to the xmpp
|
44
|
+
user. This should look like this:
|
45
|
+
|
46
|
+
.. code-block:python
|
47
|
+
|
48
|
+
class Session(BaseSession):
|
49
|
+
...
|
50
|
+
|
51
|
+
async def on_cool_chat_network_new_text_message(self, legacy_msg_event):
|
52
|
+
contact = self.contacts.by_legacy_id(legacy_msg_event.from)
|
53
|
+
contact.send_text(legacy_msg_event.text)
|
54
|
+
|
55
|
+
async def on_cool_chat_network_new_typing_event(self, legacy_typing_event):
|
56
|
+
contact = self.contacts.by_legacy_id(legacy_msg_event.from)
|
57
|
+
contact.composing()
|
58
|
+
...
|
59
|
+
|
60
|
+
Use ``carbon=True`` as a keyword arg for methods to represent an action FROM
|
61
|
+
the user TO the contact, typically when the user uses an official client to
|
62
|
+
do an action such as sending a message or marking as message as read.
|
63
|
+
This will use :xep:`0363` to impersonate the XMPP user in order.
|
64
|
+
"""
|
65
|
+
|
66
|
+
session: "BaseSession"
|
67
|
+
|
68
|
+
RESOURCE: str = "slidge"
|
69
|
+
"""
|
70
|
+
A full JID, including a resource part is required for chat states (and maybe other stuff)
|
71
|
+
to work properly. This is the name of the resource the contacts will use.
|
72
|
+
"""
|
73
|
+
PROPAGATE_PRESENCE_TO_GROUPS = True
|
74
|
+
|
75
|
+
mtype: MessageTypes = "chat"
|
76
|
+
_can_send_carbon = True
|
77
|
+
is_group = False
|
78
|
+
|
79
|
+
_ONLY_SEND_PRESENCE_CHANGES = True
|
80
|
+
|
81
|
+
STRIP_SHORT_DELAY = True
|
82
|
+
_NON_FRIEND_PRESENCES_FILTER = {"subscribe", "unsubscribed"}
|
83
|
+
|
84
|
+
_avatar_pubsub_broadcast = True
|
85
|
+
_avatar_bare_jid = True
|
86
|
+
|
87
|
+
INVITATION_RECIPIENT = True
|
88
|
+
|
89
|
+
def __init__(
|
90
|
+
self,
|
91
|
+
session: "BaseSession",
|
92
|
+
legacy_id: LegacyUserIdType,
|
93
|
+
jid_username: str,
|
94
|
+
):
|
95
|
+
"""
|
96
|
+
:param session: The session this contact is part of
|
97
|
+
:param legacy_id: The contact's legacy ID
|
98
|
+
:param jid_username: User part of this contact's 'puppet' JID.
|
99
|
+
NB: case-insensitive, and some special characters are not allowed
|
100
|
+
"""
|
101
|
+
super().__init__()
|
102
|
+
self.session = session
|
103
|
+
self.user = session.user
|
104
|
+
self.legacy_id: LegacyUserIdType = legacy_id
|
105
|
+
"""
|
106
|
+
The legacy identifier of the :term:`Legacy Contact`.
|
107
|
+
By default, this is the :term:`JID Local Part` of this
|
108
|
+
:term:`XMPP Entity`.
|
109
|
+
|
110
|
+
Controlling what values are valid and how they are translated from a
|
111
|
+
:term:`JID Local Part` is done in :meth:`.jid_username_to_legacy_id`.
|
112
|
+
Reciprocally, in :meth:`legacy_id_to_jid_username` the inverse
|
113
|
+
transformation is defined.
|
114
|
+
"""
|
115
|
+
self.jid_username = jid_username
|
116
|
+
|
117
|
+
self._name: Optional[str] = None
|
118
|
+
|
119
|
+
if self.xmpp.MARK_ALL_MESSAGES:
|
120
|
+
self._sent_order = list[str]()
|
121
|
+
|
122
|
+
self.xmpp = session.xmpp
|
123
|
+
self.jid = JID(self.jid_username + "@" + self.xmpp.boundjid.bare)
|
124
|
+
self.jid.resource = self.RESOURCE
|
125
|
+
self.log = logging.getLogger(f"{self.user.bare_jid}:{self.jid.bare}")
|
126
|
+
self.participants = set["LegacyParticipant"]()
|
127
|
+
self.is_friend: bool = False
|
128
|
+
self.__added_to_roster = False
|
129
|
+
|
130
|
+
def __repr__(self):
|
131
|
+
return f"<Contact '{self.legacy_id}'/'{self.jid.bare}'>"
|
132
|
+
|
133
|
+
def __get_subscription_string(self):
|
134
|
+
if self.is_friend:
|
135
|
+
return "both"
|
136
|
+
return "none"
|
137
|
+
|
138
|
+
def __propagate_to_participants(self, stanza: Presence):
|
139
|
+
if not self.PROPAGATE_PRESENCE_TO_GROUPS:
|
140
|
+
return
|
141
|
+
|
142
|
+
ptype = stanza["type"]
|
143
|
+
if ptype in ("available", "chat"):
|
144
|
+
func_name = "online"
|
145
|
+
elif ptype in ("xa", "unavailable"):
|
146
|
+
# we map unavailable to extended_away, because offline is
|
147
|
+
# "participant leaves the MUC"
|
148
|
+
# TODO: improve this with a clear distinction between participant
|
149
|
+
# and member list
|
150
|
+
func_name = "extended_away"
|
151
|
+
elif ptype == "busy":
|
152
|
+
func_name = "busy"
|
153
|
+
elif ptype == "away":
|
154
|
+
func_name = "away"
|
155
|
+
else:
|
156
|
+
return
|
157
|
+
|
158
|
+
last_seen: Optional[datetime.datetime] = (
|
159
|
+
stanza["idle"]["since"] if stanza.get_plugin("idle", check=True) else None
|
160
|
+
)
|
161
|
+
|
162
|
+
kw = dict(status=stanza["status"], last_seen=last_seen)
|
163
|
+
|
164
|
+
for part in self.participants:
|
165
|
+
func = getattr(part, func_name)
|
166
|
+
func(**kw)
|
167
|
+
|
168
|
+
def _send(
|
169
|
+
self, stanza: MessageOrPresenceTypeVar, carbon=False, nick=False, **send_kwargs
|
170
|
+
) -> MessageOrPresenceTypeVar:
|
171
|
+
if carbon and isinstance(stanza, Message):
|
172
|
+
stanza["to"] = self.jid.bare
|
173
|
+
stanza["from"] = self.user.jid
|
174
|
+
self._privileged_send(stanza)
|
175
|
+
return stanza # type:ignore
|
176
|
+
|
177
|
+
if isinstance(stanza, Presence):
|
178
|
+
self.__propagate_to_participants(stanza)
|
179
|
+
if (
|
180
|
+
not self.is_friend
|
181
|
+
and stanza["type"] not in self._NON_FRIEND_PRESENCES_FILTER
|
182
|
+
):
|
183
|
+
return stanza # type:ignore
|
184
|
+
if self.name and (nick or not self.is_friend):
|
185
|
+
n = self.xmpp.plugin["xep_0172"].stanza.UserNick()
|
186
|
+
n["nick"] = self.name
|
187
|
+
stanza.append(n)
|
188
|
+
if self.xmpp.MARK_ALL_MESSAGES and is_markable(stanza):
|
189
|
+
self._sent_order.append(stanza["id"])
|
190
|
+
stanza["to"] = self.user.jid
|
191
|
+
stanza.send()
|
192
|
+
return stanza
|
193
|
+
|
194
|
+
def get_msg_xmpp_id_up_to(self, horizon_xmpp_id: str):
|
195
|
+
"""
|
196
|
+
Return XMPP msg ids sent by this contact up to a given XMPP msg id.
|
197
|
+
|
198
|
+
Plugins have no reason to use this, but it is used by slidge core
|
199
|
+
for legacy networks that need to mark all messages as read (most XMPP
|
200
|
+
clients only send a read marker for the latest message).
|
201
|
+
|
202
|
+
This has side effects, if the horizon XMPP id is found, messages up to
|
203
|
+
this horizon are not cleared, to avoid sending the same read mark twice.
|
204
|
+
|
205
|
+
:param horizon_xmpp_id: The latest message
|
206
|
+
:return: A list of XMPP ids or None if horizon_xmpp_id was not found
|
207
|
+
"""
|
208
|
+
for i, xmpp_id in enumerate(self._sent_order):
|
209
|
+
if xmpp_id == horizon_xmpp_id:
|
210
|
+
break
|
211
|
+
else:
|
212
|
+
return
|
213
|
+
i += 1
|
214
|
+
res = self._sent_order[:i]
|
215
|
+
self._sent_order = self._sent_order[i:]
|
216
|
+
return res
|
217
|
+
|
218
|
+
@property
|
219
|
+
def name(self):
|
220
|
+
"""
|
221
|
+
Friendly name of the contact, as it should appear in the user's roster
|
222
|
+
"""
|
223
|
+
return self._name
|
224
|
+
|
225
|
+
@name.setter
|
226
|
+
def name(self, n: Optional[str]):
|
227
|
+
if self._name == n:
|
228
|
+
return
|
229
|
+
for p in self.participants:
|
230
|
+
p.nickname = n
|
231
|
+
self._name = n
|
232
|
+
self.xmpp.pubsub.set_nick(user=self.user, jid=self.jid.bare, nick=n)
|
233
|
+
|
234
|
+
def _post_avatar_update(self):
|
235
|
+
for p in self.participants:
|
236
|
+
self.log.debug("Propagating new avatar to %s", p.muc)
|
237
|
+
p.send_last_presence(force=True, no_cache_online=True)
|
238
|
+
|
239
|
+
def set_vcard(
|
240
|
+
self,
|
241
|
+
/,
|
242
|
+
full_name: Optional[str] = None,
|
243
|
+
given: Optional[str] = None,
|
244
|
+
surname: Optional[str] = None,
|
245
|
+
birthday: Optional[date] = None,
|
246
|
+
phone: Optional[str] = None,
|
247
|
+
phones: Iterable[str] = (),
|
248
|
+
note: Optional[str] = None,
|
249
|
+
url: Optional[str] = None,
|
250
|
+
email: Optional[str] = None,
|
251
|
+
country: Optional[str] = None,
|
252
|
+
locality: Optional[str] = None,
|
253
|
+
):
|
254
|
+
vcard = VCard4()
|
255
|
+
vcard.add_impp(f"xmpp:{self.jid.bare}")
|
256
|
+
|
257
|
+
if n := self.name:
|
258
|
+
vcard.add_nickname(n)
|
259
|
+
if full_name:
|
260
|
+
vcard["full_name"] = full_name
|
261
|
+
elif n:
|
262
|
+
vcard["full_name"] = n
|
263
|
+
|
264
|
+
if given:
|
265
|
+
vcard["given"] = given
|
266
|
+
if surname:
|
267
|
+
vcard["surname"] = surname
|
268
|
+
if birthday:
|
269
|
+
vcard["birthday"] = birthday
|
270
|
+
|
271
|
+
if note:
|
272
|
+
vcard.add_note(note)
|
273
|
+
if url:
|
274
|
+
vcard.add_url(url)
|
275
|
+
if email:
|
276
|
+
vcard.add_email(email)
|
277
|
+
if phone:
|
278
|
+
vcard.add_tel(phone)
|
279
|
+
for p in phones:
|
280
|
+
vcard.add_tel(p)
|
281
|
+
if country and locality:
|
282
|
+
vcard.add_address(country, locality)
|
283
|
+
elif country:
|
284
|
+
vcard.add_address(country, locality)
|
285
|
+
|
286
|
+
self.xmpp.vcard.set_vcard(self.jid.bare, vcard, {self.user.jid.bare})
|
287
|
+
|
288
|
+
async def add_to_roster(self, force=False):
|
289
|
+
"""
|
290
|
+
Add this contact to the user roster using :xep:`0356`
|
291
|
+
|
292
|
+
:param force: add even if the contact was already added successfully
|
293
|
+
"""
|
294
|
+
if self.__added_to_roster and not force:
|
295
|
+
return
|
296
|
+
if config.NO_ROSTER_PUSH:
|
297
|
+
log.debug("Roster push request by plugin ignored (--no-roster-push)")
|
298
|
+
return
|
299
|
+
item = {
|
300
|
+
"subscription": self.__get_subscription_string(),
|
301
|
+
"groups": [self.xmpp.ROSTER_GROUP],
|
302
|
+
}
|
303
|
+
if (n := self.name) is not None:
|
304
|
+
item["name"] = n
|
305
|
+
kw = dict(
|
306
|
+
jid=self.user.jid,
|
307
|
+
roster_items={self.jid.bare: item},
|
308
|
+
)
|
309
|
+
try:
|
310
|
+
await self._set_roster(**kw)
|
311
|
+
except PermissionError:
|
312
|
+
warnings.warn(
|
313
|
+
"Slidge does not have privileges to add contacts to the roster. Refer"
|
314
|
+
" to https://slidge.readthedocs.io/en/latest/admin/xmpp_server.html for"
|
315
|
+
" more info."
|
316
|
+
)
|
317
|
+
if config.ROSTER_PUSH_PRESENCE_SUBSCRIPTION_REQUEST_FALLBACK:
|
318
|
+
self.send_friend_request(
|
319
|
+
f"I'm already your friend on {self.xmpp.COMPONENT_TYPE}, but "
|
320
|
+
"slidge is not allowed to manage your roster."
|
321
|
+
)
|
322
|
+
return
|
323
|
+
except IqError as e:
|
324
|
+
self.log.warning("Could not add to roster", exc_info=e)
|
325
|
+
else:
|
326
|
+
# we only broadcast pubsub events for contacts added to the roster
|
327
|
+
# so if something was set before, we need to push it now
|
328
|
+
self.__added_to_roster = True
|
329
|
+
self.xmpp.loop.create_task(self.__broadcast_pubsub_items())
|
330
|
+
self.send_last_presence()
|
331
|
+
|
332
|
+
async def __broadcast_pubsub_items(self):
|
333
|
+
await self.xmpp.pubsub.broadcast_all(JID(self.jid.bare), self.user.jid)
|
334
|
+
|
335
|
+
async def _set_roster(self, **kw):
|
336
|
+
try:
|
337
|
+
return await self.xmpp["xep_0356"].set_roster(**kw)
|
338
|
+
except PermissionError:
|
339
|
+
return await self.xmpp["xep_0356_old"].set_roster(**kw)
|
340
|
+
|
341
|
+
def send_friend_request(self, text: Optional[str] = None):
|
342
|
+
presence = self._make_presence(ptype="subscribe", pstatus=text, bare=True)
|
343
|
+
self._send(presence, nick=True)
|
344
|
+
|
345
|
+
async def accept_friend_request(self, text: Optional[str] = None):
|
346
|
+
"""
|
347
|
+
Call this to signify that this Contact has accepted to be a friend
|
348
|
+
of the user.
|
349
|
+
|
350
|
+
:param text: Optional message from the friend to the user
|
351
|
+
"""
|
352
|
+
self.is_friend = True
|
353
|
+
self.log.debug("Accepting friend request")
|
354
|
+
presence = self._make_presence(ptype="subscribed", pstatus=text, bare=True)
|
355
|
+
self._send(presence, nick=True)
|
356
|
+
self.send_last_presence()
|
357
|
+
await self.__broadcast_pubsub_items()
|
358
|
+
self.log.debug("Accepted friend request")
|
359
|
+
|
360
|
+
def reject_friend_request(self, text: Optional[str] = None):
|
361
|
+
"""
|
362
|
+
Call this to signify that this Contact has refused to be a contact
|
363
|
+
of the user (or that they don't want to be friends anymore)
|
364
|
+
|
365
|
+
:param text: Optional message from the non-friend to the user
|
366
|
+
"""
|
367
|
+
presence = self._make_presence(ptype="unsubscribed", pstatus=text, bare=True)
|
368
|
+
self.offline()
|
369
|
+
self._send(presence, nick=True)
|
370
|
+
self.is_friend = False
|
371
|
+
|
372
|
+
async def on_friend_request(self, text=""):
|
373
|
+
"""
|
374
|
+
Called when receiving a "subscribe" presence, ie, "I would like to add
|
375
|
+
you to my contacts/friends", from the user to this contact.
|
376
|
+
|
377
|
+
In XMPP terms: "I would like to receive your presence updates"
|
378
|
+
|
379
|
+
This is only called if self.is_friend = False. If self.is_friend = True,
|
380
|
+
slidge will automatically "accept the friend request", ie, reply with
|
381
|
+
a "subscribed" presence.
|
382
|
+
|
383
|
+
When called, a 'friend request event' should be sent to the legacy
|
384
|
+
service, and when the contact responds, you should either call
|
385
|
+
self.accept_subscription() or self.reject_subscription()
|
386
|
+
"""
|
387
|
+
pass
|
388
|
+
|
389
|
+
async def on_friend_delete(self, text=""):
|
390
|
+
"""
|
391
|
+
Called when receiving an "unsubscribed" presence, ie, "I would like to
|
392
|
+
remove you to my contacts/friends" or "I refuse your friend request"
|
393
|
+
from the user to this contact.
|
394
|
+
|
395
|
+
In XMPP terms: "You won't receive my presence updates anymore (or you
|
396
|
+
never have)".
|
397
|
+
"""
|
398
|
+
pass
|
399
|
+
|
400
|
+
async def on_friend_accept(self):
|
401
|
+
"""
|
402
|
+
Called when receiving a "subscribed" presence, ie, "I accept to be
|
403
|
+
your/confirm that you are my friend" from the user to this contact.
|
404
|
+
|
405
|
+
In XMPP terms: "You will receive my presence updates".
|
406
|
+
"""
|
407
|
+
pass
|
408
|
+
|
409
|
+
def unsubscribe(self):
|
410
|
+
"""
|
411
|
+
(internal use by slidge)
|
412
|
+
|
413
|
+
Send an "unsubscribe", "unsubscribed", "unavailable" presence sequence
|
414
|
+
from this contact to the user, ie, "this contact has removed you from
|
415
|
+
their 'friends'".
|
416
|
+
"""
|
417
|
+
for ptype in "unsubscribe", "unsubscribed", "unavailable":
|
418
|
+
self.xmpp.send_presence(pfrom=self.jid, pto=self.user.jid.bare, ptype=ptype) # type: ignore
|
419
|
+
|
420
|
+
async def update_info(self):
|
421
|
+
"""
|
422
|
+
Fetch information about this contact from the legacy network
|
423
|
+
|
424
|
+
This is awaited on Contact instantiation, and should be overridden to
|
425
|
+
update the nickname, avatar, vcard [...] of this contact, by making
|
426
|
+
"legacy API calls".
|
427
|
+
|
428
|
+
To take advantage of the slidge avatar cache, you can check the .avatar
|
429
|
+
property to retrieve the "legacy file ID" of the cached avatar. If there
|
430
|
+
is no change, you should not call
|
431
|
+
:py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar` or attempt
|
432
|
+
to modify the ``.avatar`` property.
|
433
|
+
"""
|
434
|
+
pass
|
435
|
+
|
436
|
+
async def fetch_vcard(self):
|
437
|
+
"""
|
438
|
+
It the legacy network doesn't like that you fetch too many profiles on startup,
|
439
|
+
it's also possible to fetch it here, which will be called when XMPP clients
|
440
|
+
of the user request the vcard, if it hasn't been fetched before
|
441
|
+
:return:
|
442
|
+
"""
|
443
|
+
pass
|
444
|
+
|
445
|
+
|
446
|
+
def is_markable(stanza: Union[Message, Presence]):
|
447
|
+
if isinstance(stanza, Presence):
|
448
|
+
return False
|
449
|
+
return bool(stanza["body"])
|
450
|
+
|
451
|
+
|
452
|
+
log = logging.getLogger(__name__)
|
slidge/contact/roster.py
ADDED
@@ -0,0 +1,192 @@
|
|
1
|
+
import asyncio
|
2
|
+
import logging
|
3
|
+
from typing import TYPE_CHECKING, Generic, Optional, Type
|
4
|
+
|
5
|
+
from slixmpp import JID
|
6
|
+
from slixmpp.jid import JID_UNESCAPE_TRANSFORMATIONS, _unescape_node
|
7
|
+
|
8
|
+
from ..core.mixins.lock import NamedLockMixin
|
9
|
+
from ..util import SubclassableOnce
|
10
|
+
from ..util.types import LegacyContactType, LegacyUserIdType
|
11
|
+
from .contact import LegacyContact
|
12
|
+
|
13
|
+
if TYPE_CHECKING:
|
14
|
+
from ..core.session import BaseSession
|
15
|
+
|
16
|
+
|
17
|
+
class ContactIsUser(Exception):
|
18
|
+
pass
|
19
|
+
|
20
|
+
|
21
|
+
class LegacyRoster(
|
22
|
+
Generic[LegacyUserIdType, LegacyContactType],
|
23
|
+
NamedLockMixin,
|
24
|
+
metaclass=SubclassableOnce,
|
25
|
+
):
|
26
|
+
"""
|
27
|
+
Virtual roster of a gateway user, that allows to represent all
|
28
|
+
of their contacts as singleton instances (if used properly and not too bugged).
|
29
|
+
|
30
|
+
Every :class:`.BaseSession` instance will have its own :class:`.LegacyRoster` instance
|
31
|
+
accessible via the :attr:`.BaseSession.contacts` attribute.
|
32
|
+
|
33
|
+
Typically, you will mostly use the :meth:`.LegacyRoster.by_legacy_id` function to
|
34
|
+
retrieve a contact instance.
|
35
|
+
|
36
|
+
You might need to override :meth:`.LegacyRoster.legacy_id_to_jid_username` and/or
|
37
|
+
:meth:`.LegacyRoster.jid_username_to_legacy_id` to incorporate some custom logic
|
38
|
+
if you need some characters when translation JID user parts and legacy IDs.
|
39
|
+
"""
|
40
|
+
|
41
|
+
def __init__(self, session: "BaseSession"):
|
42
|
+
self._contact_cls: Type[LegacyContactType] = (
|
43
|
+
LegacyContact.get_self_or_unique_subclass()
|
44
|
+
)
|
45
|
+
self._contact_cls.xmpp = session.xmpp
|
46
|
+
|
47
|
+
self.session = session
|
48
|
+
self._contacts_by_bare_jid: dict[str, LegacyContactType] = {}
|
49
|
+
self._contacts_by_legacy_id: dict[LegacyUserIdType, LegacyContactType] = {}
|
50
|
+
self.log = logging.getLogger(f"{self.session.user.bare_jid}:roster")
|
51
|
+
self.user_legacy_id: Optional[LegacyUserIdType] = None
|
52
|
+
self.ready: asyncio.Future[bool] = self.session.xmpp.loop.create_future()
|
53
|
+
super().__init__()
|
54
|
+
|
55
|
+
def __repr__(self):
|
56
|
+
return f"<Roster of {self.session.user}>"
|
57
|
+
|
58
|
+
def __iter__(self):
|
59
|
+
return iter(self._contacts_by_legacy_id.values())
|
60
|
+
|
61
|
+
async def __finish_init_contact(
|
62
|
+
self, legacy_id: LegacyUserIdType, jid_username: str, *args, **kwargs
|
63
|
+
):
|
64
|
+
c = self._contact_cls(self.session, legacy_id, jid_username, *args, **kwargs)
|
65
|
+
async with self.lock(("finish", c)):
|
66
|
+
if legacy_id in self._contacts_by_legacy_id:
|
67
|
+
self.log.debug("Already updated %s", c)
|
68
|
+
return c
|
69
|
+
await c.avatar_wrap_update_info()
|
70
|
+
self._contacts_by_legacy_id[legacy_id] = c
|
71
|
+
self._contacts_by_bare_jid[c.jid.bare] = c
|
72
|
+
return c
|
73
|
+
|
74
|
+
def known_contacts(self, only_friends=True) -> dict[str, LegacyContactType]:
|
75
|
+
if only_friends:
|
76
|
+
return {j: c for j, c in self._contacts_by_bare_jid.items() if c.is_friend}
|
77
|
+
return self._contacts_by_bare_jid
|
78
|
+
|
79
|
+
async def by_jid(self, contact_jid: JID) -> LegacyContactType:
|
80
|
+
# """
|
81
|
+
# Retrieve a contact by their JID
|
82
|
+
#
|
83
|
+
# If the contact was not instantiated before, it will be created
|
84
|
+
# using :meth:`slidge.LegacyRoster.jid_username_to_legacy_id` to infer their
|
85
|
+
# legacy user ID.
|
86
|
+
#
|
87
|
+
# :param contact_jid:
|
88
|
+
# :return:
|
89
|
+
# """
|
90
|
+
username = contact_jid.node
|
91
|
+
async with self.lock(("username", username)):
|
92
|
+
bare = contact_jid.bare
|
93
|
+
c = self._contacts_by_bare_jid.get(bare)
|
94
|
+
if c is None:
|
95
|
+
legacy_id = await self.jid_username_to_legacy_id(username)
|
96
|
+
log.debug("Contact %s not found", contact_jid)
|
97
|
+
if self.get_lock(("legacy_id", legacy_id)):
|
98
|
+
log.debug("Already updating %s", contact_jid)
|
99
|
+
return await self.by_legacy_id(legacy_id)
|
100
|
+
c = await self.__finish_init_contact(legacy_id, username)
|
101
|
+
return c
|
102
|
+
|
103
|
+
async def by_legacy_id(
|
104
|
+
self, legacy_id: LegacyUserIdType, *args, **kwargs
|
105
|
+
) -> LegacyContactType:
|
106
|
+
"""
|
107
|
+
Retrieve a contact by their legacy_id
|
108
|
+
|
109
|
+
If the contact was not instantiated before, it will be created
|
110
|
+
using :meth:`slidge.LegacyRoster.legacy_id_to_jid_username` to infer their
|
111
|
+
legacy user ID.
|
112
|
+
|
113
|
+
:param legacy_id:
|
114
|
+
:param args: arbitrary additional positional arguments passed to the contact constructor.
|
115
|
+
Requires subclassing LegacyContact.__init__ to accept those.
|
116
|
+
This is useful for networks where you fetch the contact list and information
|
117
|
+
about these contacts in a single request
|
118
|
+
:param kwargs: arbitrary keyword arguments passed to the contact constructor
|
119
|
+
:return:
|
120
|
+
"""
|
121
|
+
if legacy_id == self.user_legacy_id:
|
122
|
+
raise ContactIsUser
|
123
|
+
async with self.lock(("legacy_id", legacy_id)):
|
124
|
+
c = self._contacts_by_legacy_id.get(legacy_id)
|
125
|
+
if c is None:
|
126
|
+
username = await self.legacy_id_to_jid_username(legacy_id)
|
127
|
+
log.debug("Contact %s not found", legacy_id)
|
128
|
+
if self.get_lock(("username", username)):
|
129
|
+
log.debug("Already updating %s", username)
|
130
|
+
jid = JID()
|
131
|
+
jid.node = username
|
132
|
+
jid.domain = self.session.xmpp.boundjid.bare
|
133
|
+
return await self.by_jid(jid)
|
134
|
+
c = await self.__finish_init_contact(
|
135
|
+
legacy_id, username, *args, **kwargs
|
136
|
+
)
|
137
|
+
return c
|
138
|
+
|
139
|
+
async def by_stanza(self, s) -> LegacyContact:
|
140
|
+
# """
|
141
|
+
# Retrieve a contact by the destination of a stanza
|
142
|
+
#
|
143
|
+
# See :meth:`slidge.Roster.by_legacy_id` for more info.
|
144
|
+
#
|
145
|
+
# :param s:
|
146
|
+
# :return:
|
147
|
+
# """
|
148
|
+
return await self.by_jid(s.get_to())
|
149
|
+
|
150
|
+
async def legacy_id_to_jid_username(self, legacy_id: LegacyUserIdType) -> str:
|
151
|
+
"""
|
152
|
+
Convert a legacy ID to a valid 'user' part of a JID
|
153
|
+
|
154
|
+
Should be overridden for cases where the str conversion of
|
155
|
+
the legacy_id is not enough, e.g., if it is case-sensitive or contains
|
156
|
+
forbidden characters not covered by :xep:`0106`.
|
157
|
+
|
158
|
+
:param legacy_id:
|
159
|
+
"""
|
160
|
+
return str(legacy_id).translate(ESCAPE_TABLE)
|
161
|
+
|
162
|
+
async def jid_username_to_legacy_id(self, jid_username: str) -> LegacyUserIdType:
|
163
|
+
"""
|
164
|
+
Convert a JID user part to a legacy ID.
|
165
|
+
|
166
|
+
Should be overridden in case legacy IDs are not strings, or more generally
|
167
|
+
for any case where the username part of a JID (unescaped with to the mapping
|
168
|
+
defined by :xep:`0106`) is not enough to identify a contact on the legacy network.
|
169
|
+
|
170
|
+
Default implementation is an identity operation
|
171
|
+
|
172
|
+
:param jid_username: User part of a JID, ie "user" in "user@example.com"
|
173
|
+
:return: An identifier for the user on the legacy network.
|
174
|
+
"""
|
175
|
+
return _unescape_node(jid_username)
|
176
|
+
|
177
|
+
async def fill(self):
|
178
|
+
"""
|
179
|
+
Populate slidge's "virtual roster".
|
180
|
+
|
181
|
+
Override this and in it, ``await self.by_legacy_id(contact_id)``
|
182
|
+
for the every legacy contacts of the user for which you'd like to
|
183
|
+
set an avatar, nickname, vcard…
|
184
|
+
|
185
|
+
Await ``Contact.add_to_roster()`` in here to add the contact to the
|
186
|
+
user's XMPP roster.
|
187
|
+
"""
|
188
|
+
pass
|
189
|
+
|
190
|
+
|
191
|
+
ESCAPE_TABLE = "".maketrans({v: k for k, v in JID_UNESCAPE_TRANSFORMATIONS.items()})
|
192
|
+
log = logging.getLogger(__name__)
|
slidge/core/__init__.py
ADDED