slidge 0.1.0rc1__py3-none-any.whl → 0.1.2__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (164) hide show
  1. slidge/__init__.py +54 -31
  2. slidge/__main__.py +51 -5
  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 +2 -0
  15. slidge/core/cache.py +121 -39
  16. slidge/core/config.py +116 -11
  17. slidge/core/gateway/__init__.py +3 -0
  18. slidge/core/gateway/base.py +895 -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 +795 -0
  29. slidge/core/gateway/vcard_temp.py +130 -0
  30. slidge/core/mixins/__init__.py +9 -1
  31. slidge/core/mixins/attachment.py +506 -0
  32. slidge/core/mixins/avatar.py +167 -0
  33. slidge/core/mixins/base.py +6 -19
  34. slidge/core/mixins/disco.py +66 -15
  35. slidge/core/mixins/lock.py +31 -0
  36. slidge/core/mixins/message.py +254 -252
  37. slidge/core/mixins/message_maker.py +154 -0
  38. slidge/core/mixins/presence.py +128 -31
  39. slidge/core/mixins/recipient.py +43 -0
  40. slidge/core/pubsub.py +275 -116
  41. slidge/core/session.py +586 -518
  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 +458 -0
  46. slidge/group/room.py +1103 -0
  47. slidge/migration.py +18 -0
  48. slidge/slixfix/__init__.py +68 -0
  49. slidge/{util/xep_0050 → slixfix/link_preview}/__init__.py +4 -5
  50. slidge/slixfix/link_preview/link_preview.py +17 -0
  51. slidge/slixfix/link_preview/stanza.py +99 -0
  52. slidge/slixfix/roster.py +60 -0
  53. slidge/{util → slixfix}/xep_0077/register.py +1 -2
  54. slidge/slixfix/xep_0077/stanza.py +104 -0
  55. slidge/{util → slixfix}/xep_0100/gateway.py +17 -12
  56. slidge/slixfix/xep_0153/__init__.py +10 -0
  57. slidge/slixfix/xep_0153/stanza.py +25 -0
  58. slidge/slixfix/xep_0153/vcard_avatar.py +23 -0
  59. slidge/slixfix/xep_0264/__init__.py +5 -0
  60. slidge/slixfix/xep_0264/stanza.py +36 -0
  61. slidge/slixfix/xep_0264/thumbnail.py +23 -0
  62. slidge/slixfix/xep_0292/__init__.py +5 -0
  63. slidge/slixfix/xep_0292/vcard4.py +100 -0
  64. slidge/slixfix/xep_0313/__init__.py +12 -0
  65. slidge/slixfix/xep_0313/mam.py +262 -0
  66. slidge/slixfix/xep_0313/stanza.py +359 -0
  67. slidge/slixfix/xep_0317/__init__.py +5 -0
  68. slidge/slixfix/xep_0317/hats.py +17 -0
  69. slidge/slixfix/xep_0317/stanza.py +28 -0
  70. slidge/{util → slixfix}/xep_0356_old/privilege.py +9 -7
  71. slidge/slixfix/xep_0424/__init__.py +9 -0
  72. slidge/slixfix/xep_0424/retraction.py +77 -0
  73. slidge/slixfix/xep_0424/stanza.py +28 -0
  74. slidge/slixfix/xep_0490/__init__.py +8 -0
  75. slidge/slixfix/xep_0490/mds.py +47 -0
  76. slidge/slixfix/xep_0490/stanza.py +17 -0
  77. slidge/util/__init__.py +4 -6
  78. slidge/util/archive_msg.py +61 -0
  79. slidge/util/conf.py +25 -4
  80. slidge/util/db.py +23 -69
  81. slidge/util/schema.sql +126 -0
  82. slidge/util/sql.py +508 -0
  83. slidge/util/test.py +136 -86
  84. slidge/util/types.py +155 -14
  85. slidge/util/util.py +225 -51
  86. slidge-0.1.2.dist-info/METADATA +111 -0
  87. slidge-0.1.2.dist-info/RECORD +96 -0
  88. {slidge-0.1.0rc1.dist-info → slidge-0.1.2.dist-info}/WHEEL +1 -1
  89. slidge/core/adhoc.py +0 -492
  90. slidge/core/chat_command.py +0 -197
  91. slidge/core/contact.py +0 -441
  92. slidge/core/disco.py +0 -59
  93. slidge/core/gateway.py +0 -899
  94. slidge/core/muc/__init__.py +0 -3
  95. slidge/core/muc/bookmarks.py +0 -74
  96. slidge/core/muc/participant.py +0 -152
  97. slidge/core/muc/room.py +0 -348
  98. slidge/plugins/discord/__init__.py +0 -121
  99. slidge/plugins/discord/client.py +0 -121
  100. slidge/plugins/discord/session.py +0 -172
  101. slidge/plugins/dummy.py +0 -334
  102. slidge/plugins/facebook.py +0 -591
  103. slidge/plugins/hackernews.py +0 -209
  104. slidge/plugins/mattermost/__init__.py +0 -1
  105. slidge/plugins/mattermost/api.py +0 -288
  106. slidge/plugins/mattermost/gateway.py +0 -417
  107. slidge/plugins/mattermost/websocket.py +0 -248
  108. slidge/plugins/signal/__init__.py +0 -4
  109. slidge/plugins/signal/config.py +0 -4
  110. slidge/plugins/signal/contact.py +0 -104
  111. slidge/plugins/signal/gateway.py +0 -379
  112. slidge/plugins/signal/group.py +0 -76
  113. slidge/plugins/signal/session.py +0 -515
  114. slidge/plugins/signal/txt.py +0 -13
  115. slidge/plugins/signal/util.py +0 -32
  116. slidge/plugins/skype.py +0 -310
  117. slidge/plugins/steam.py +0 -400
  118. slidge/plugins/telegram/__init__.py +0 -6
  119. slidge/plugins/telegram/client.py +0 -325
  120. slidge/plugins/telegram/config.py +0 -21
  121. slidge/plugins/telegram/contact.py +0 -154
  122. slidge/plugins/telegram/gateway.py +0 -182
  123. slidge/plugins/telegram/group.py +0 -184
  124. slidge/plugins/telegram/session.py +0 -275
  125. slidge/plugins/telegram/util.py +0 -153
  126. slidge/plugins/whatsapp/__init__.py +0 -6
  127. slidge/plugins/whatsapp/config.py +0 -17
  128. slidge/plugins/whatsapp/contact.py +0 -33
  129. slidge/plugins/whatsapp/event.go +0 -455
  130. slidge/plugins/whatsapp/gateway.go +0 -156
  131. slidge/plugins/whatsapp/gateway.py +0 -69
  132. slidge/plugins/whatsapp/go.mod +0 -17
  133. slidge/plugins/whatsapp/go.sum +0 -22
  134. slidge/plugins/whatsapp/session.go +0 -371
  135. slidge/plugins/whatsapp/session.py +0 -370
  136. slidge/util/xep_0030/__init__.py +0 -13
  137. slidge/util/xep_0030/disco.py +0 -811
  138. slidge/util/xep_0030/stanza/__init__.py +0 -7
  139. slidge/util/xep_0030/stanza/info.py +0 -270
  140. slidge/util/xep_0030/stanza/items.py +0 -147
  141. slidge/util/xep_0030/static.py +0 -467
  142. slidge/util/xep_0050/adhoc.py +0 -631
  143. slidge/util/xep_0050/stanza.py +0 -180
  144. slidge/util/xep_0077/stanza.py +0 -71
  145. slidge/util/xep_0292/__init__.py +0 -1
  146. slidge/util/xep_0292/stanza.py +0 -167
  147. slidge/util/xep_0292/vcard4.py +0 -74
  148. slidge/util/xep_0356/__init__.py +0 -7
  149. slidge/util/xep_0356/permissions.py +0 -35
  150. slidge/util/xep_0356/privilege.py +0 -160
  151. slidge/util/xep_0356/stanza.py +0 -44
  152. slidge/util/xep_0461/__init__.py +0 -6
  153. slidge/util/xep_0461/reply.py +0 -48
  154. slidge/util/xep_0461/stanza.py +0 -80
  155. slidge-0.1.0rc1.dist-info/METADATA +0 -171
  156. slidge-0.1.0rc1.dist-info/RECORD +0 -99
  157. /slidge/{plugins/__init__.py → py.typed} +0 -0
  158. /slidge/{util → slixfix}/xep_0077/__init__.py +0 -0
  159. /slidge/{util → slixfix}/xep_0100/__init__.py +0 -0
  160. /slidge/{util → slixfix}/xep_0100/stanza.py +0 -0
  161. /slidge/{util → slixfix}/xep_0356_old/__init__.py +0 -0
  162. /slidge/{util → slixfix}/xep_0356_old/stanza.py +0 -0
  163. {slidge-0.1.0rc1.dist-info → slidge-0.1.2.dist-info}/LICENSE +0 -0
  164. {slidge-0.1.0rc1.dist-info → slidge-0.1.2.dist-info}/entry_points.txt +0 -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.session.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__)
slidge/core/__init__.py CHANGED
@@ -1 +1,3 @@
1
1
  from .pubsub import PubSubComponent
2
+
3
+ __all__ = ("PubSubComponent",)