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.
Files changed (96) hide show
  1. slidge/__init__.py +61 -0
  2. slidge/__main__.py +192 -0
  3. slidge/command/__init__.py +28 -0
  4. slidge/command/adhoc.py +258 -0
  5. slidge/command/admin.py +193 -0
  6. slidge/command/base.py +441 -0
  7. slidge/command/categories.py +3 -0
  8. slidge/command/chat_command.py +288 -0
  9. slidge/command/register.py +179 -0
  10. slidge/command/user.py +250 -0
  11. slidge/contact/__init__.py +8 -0
  12. slidge/contact/contact.py +452 -0
  13. slidge/contact/roster.py +192 -0
  14. slidge/core/__init__.py +3 -0
  15. slidge/core/cache.py +183 -0
  16. slidge/core/config.py +209 -0
  17. slidge/core/gateway/__init__.py +3 -0
  18. slidge/core/gateway/base.py +892 -0
  19. slidge/core/gateway/caps.py +63 -0
  20. slidge/core/gateway/delivery_receipt.py +52 -0
  21. slidge/core/gateway/disco.py +80 -0
  22. slidge/core/gateway/mam.py +75 -0
  23. slidge/core/gateway/muc_admin.py +35 -0
  24. slidge/core/gateway/ping.py +66 -0
  25. slidge/core/gateway/presence.py +95 -0
  26. slidge/core/gateway/registration.py +53 -0
  27. slidge/core/gateway/search.py +102 -0
  28. slidge/core/gateway/session_dispatcher.py +757 -0
  29. slidge/core/gateway/vcard_temp.py +130 -0
  30. slidge/core/mixins/__init__.py +19 -0
  31. slidge/core/mixins/attachment.py +506 -0
  32. slidge/core/mixins/avatar.py +167 -0
  33. slidge/core/mixins/base.py +31 -0
  34. slidge/core/mixins/disco.py +130 -0
  35. slidge/core/mixins/lock.py +31 -0
  36. slidge/core/mixins/message.py +398 -0
  37. slidge/core/mixins/message_maker.py +154 -0
  38. slidge/core/mixins/presence.py +217 -0
  39. slidge/core/mixins/recipient.py +43 -0
  40. slidge/core/pubsub.py +525 -0
  41. slidge/core/session.py +752 -0
  42. slidge/group/__init__.py +10 -0
  43. slidge/group/archive.py +125 -0
  44. slidge/group/bookmarks.py +163 -0
  45. slidge/group/participant.py +440 -0
  46. slidge/group/room.py +1095 -0
  47. slidge/migration.py +18 -0
  48. slidge/py.typed +0 -0
  49. slidge/slixfix/__init__.py +68 -0
  50. slidge/slixfix/link_preview/__init__.py +10 -0
  51. slidge/slixfix/link_preview/link_preview.py +17 -0
  52. slidge/slixfix/link_preview/stanza.py +99 -0
  53. slidge/slixfix/roster.py +60 -0
  54. slidge/slixfix/xep_0077/__init__.py +10 -0
  55. slidge/slixfix/xep_0077/register.py +289 -0
  56. slidge/slixfix/xep_0077/stanza.py +104 -0
  57. slidge/slixfix/xep_0100/__init__.py +5 -0
  58. slidge/slixfix/xep_0100/gateway.py +121 -0
  59. slidge/slixfix/xep_0100/stanza.py +9 -0
  60. slidge/slixfix/xep_0153/__init__.py +10 -0
  61. slidge/slixfix/xep_0153/stanza.py +25 -0
  62. slidge/slixfix/xep_0153/vcard_avatar.py +23 -0
  63. slidge/slixfix/xep_0264/__init__.py +5 -0
  64. slidge/slixfix/xep_0264/stanza.py +36 -0
  65. slidge/slixfix/xep_0264/thumbnail.py +23 -0
  66. slidge/slixfix/xep_0292/__init__.py +5 -0
  67. slidge/slixfix/xep_0292/vcard4.py +100 -0
  68. slidge/slixfix/xep_0313/__init__.py +12 -0
  69. slidge/slixfix/xep_0313/mam.py +262 -0
  70. slidge/slixfix/xep_0313/stanza.py +359 -0
  71. slidge/slixfix/xep_0317/__init__.py +5 -0
  72. slidge/slixfix/xep_0317/hats.py +17 -0
  73. slidge/slixfix/xep_0317/stanza.py +28 -0
  74. slidge/slixfix/xep_0356_old/__init__.py +7 -0
  75. slidge/slixfix/xep_0356_old/privilege.py +167 -0
  76. slidge/slixfix/xep_0356_old/stanza.py +44 -0
  77. slidge/slixfix/xep_0424/__init__.py +9 -0
  78. slidge/slixfix/xep_0424/retraction.py +77 -0
  79. slidge/slixfix/xep_0424/stanza.py +28 -0
  80. slidge/slixfix/xep_0490/__init__.py +8 -0
  81. slidge/slixfix/xep_0490/mds.py +47 -0
  82. slidge/slixfix/xep_0490/stanza.py +17 -0
  83. slidge/util/__init__.py +15 -0
  84. slidge/util/archive_msg.py +61 -0
  85. slidge/util/conf.py +206 -0
  86. slidge/util/db.py +229 -0
  87. slidge/util/schema.sql +126 -0
  88. slidge/util/sql.py +508 -0
  89. slidge/util/test.py +295 -0
  90. slidge/util/types.py +180 -0
  91. slidge/util/util.py +295 -0
  92. slidge-0.1.0.dist-info/LICENSE +661 -0
  93. slidge-0.1.0.dist-info/METADATA +109 -0
  94. slidge-0.1.0.dist-info/RECORD +96 -0
  95. slidge-0.1.0.dist-info/WHEEL +4 -0
  96. 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__)
@@ -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__)
@@ -0,0 +1,3 @@
1
+ from .pubsub import PubSubComponent
2
+
3
+ __all__ = ("PubSubComponent",)