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
slidge/group/room.py ADDED
@@ -0,0 +1,1095 @@
1
+ import logging
2
+ import re
3
+ import string
4
+ import warnings
5
+ from copy import copy
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import TYPE_CHECKING, Generic, Optional, Union
8
+ from uuid import uuid4
9
+
10
+ from slixmpp import JID, Iq, Message, Presence
11
+ from slixmpp.exceptions import IqError, XMPPError
12
+ from slixmpp.jid import _unescape_node
13
+ from slixmpp.plugins.xep_0004 import Form
14
+ from slixmpp.plugins.xep_0060.stanza import Item
15
+ from slixmpp.plugins.xep_0082 import parse as str_to_datetime
16
+ from slixmpp.xmlstream import ET
17
+
18
+ from ..contact.roster import ContactIsUser
19
+ from ..core import config
20
+ from ..core.mixins.avatar import AvatarMixin
21
+ from ..core.mixins.disco import ChatterDiscoMixin
22
+ from ..core.mixins.lock import NamedLockMixin
23
+ from ..core.mixins.recipient import ReactionRecipientMixin, ThreadRecipientMixin
24
+ from ..util import ABCSubclassableOnceAtMost
25
+ from ..util.types import (
26
+ LegacyGroupIdType,
27
+ LegacyMessageType,
28
+ LegacyParticipantType,
29
+ LegacyUserIdType,
30
+ Mention,
31
+ MucAffiliation,
32
+ MucType,
33
+ )
34
+ from ..util.util import deprecated
35
+ from .archive import MessageArchive
36
+
37
+ if TYPE_CHECKING:
38
+ from ..contact import LegacyContact
39
+ from ..core.gateway import BaseGateway
40
+ from ..core.session import BaseSession
41
+
42
+ ADMIN_NS = "http://jabber.org/protocol/muc#admin"
43
+
44
+
45
+ class LegacyMUC(
46
+ Generic[
47
+ LegacyGroupIdType, LegacyMessageType, LegacyParticipantType, LegacyUserIdType
48
+ ],
49
+ AvatarMixin,
50
+ NamedLockMixin,
51
+ ChatterDiscoMixin,
52
+ ReactionRecipientMixin,
53
+ ThreadRecipientMixin,
54
+ metaclass=ABCSubclassableOnceAtMost,
55
+ ):
56
+ """
57
+ A room, a.k.a. a Multi-User Chat.
58
+
59
+ MUC instances are obtained by calling :py:meth:`slidge.group.bookmarks.LegacyBookmarks`
60
+ on the user's :py:class:`slidge.core.session.BaseSession`.
61
+ """
62
+
63
+ subject_date: Optional[datetime] = None
64
+ n_participants: Optional[int] = None
65
+ max_history_fetch = 100
66
+
67
+ type = MucType.CHANNEL
68
+ is_group = True
69
+
70
+ DISCO_TYPE = "text"
71
+ DISCO_CATEGORY = "conference"
72
+ DISCO_NAME = "unnamed-room"
73
+
74
+ STABLE_ARCHIVE = False
75
+ """
76
+ Because legacy events like reactions, editions, etc. don't all map to a stanza
77
+ with a proper legacy ID, slidge usually cannot guarantee the stability of the archive
78
+ across restarts.
79
+
80
+ Set this to True if you know what you're doing, but realistically, this can't
81
+ be set to True until archive is permanently stored on disk by slidge.
82
+
83
+ This is just a flag on archive responses that most clients ignore anyway.
84
+ """
85
+
86
+ KEEP_BACKFILLED_PARTICIPANTS = False
87
+ """
88
+ Set this to ``True`` if the participant list is not full after calling
89
+ ``fill_participants()``. This is a workaround for networks with huge
90
+ participant lists which do not map really well the MUCs where all presences
91
+ are sent on join.
92
+ It allows to ensure that the participants that last spoke (within the
93
+ ``fill_history()`` method are effectively participants, thus making possible
94
+ for XMPP clients to fetch their avatars.
95
+ """
96
+
97
+ _ALL_INFO_FILLED_ON_STARTUP = False
98
+ """
99
+ Set this to true if the fill_participants() / fill_participants() design does not
100
+ fit the legacy API, ie, no lazy loading of the participant list and history.
101
+ """
102
+
103
+ HAS_DESCRIPTION = True
104
+ """
105
+ Set this to false if the legacy network does not allow setting a description
106
+ for the group. In this case the description field will not be present in the
107
+ room configuration form.
108
+ """
109
+
110
+ HAS_SUBJECT = True
111
+ """
112
+ Set this to false if the legacy network does not allow setting a subject
113
+ (sometimes also called topic) for the group. In this case, as a subject is
114
+ recommended by :xep:`0045` ("SHALL"), the description (or the group name as
115
+ ultimate fallback) will be used as the room subject.
116
+ By setting this to false, an error will be returned when the :term:`User`
117
+ tries to set the room subject.
118
+ """
119
+
120
+ _avatar_pubsub_broadcast = False
121
+ _avatar_bare_jid = True
122
+
123
+ def __init__(self, session: "BaseSession", legacy_id: LegacyGroupIdType, jid: JID):
124
+ from .participant import LegacyParticipant
125
+
126
+ self.session = session
127
+ self.xmpp: "BaseGateway" = session.xmpp
128
+ self.user = session.user
129
+ self.log = logging.getLogger(f"{self.user.bare_jid}:muc:{jid}")
130
+
131
+ self.legacy_id = legacy_id
132
+ self.jid = jid
133
+
134
+ self.user_resources = set[str]()
135
+
136
+ self.Participant = LegacyParticipant.get_self_or_unique_subclass()
137
+
138
+ self.xmpp.add_event_handler(
139
+ "presence_unavailable", self._on_presence_unavailable
140
+ )
141
+
142
+ self._subject = ""
143
+ self.subject_setter: Union[str, "LegacyContact", "LegacyParticipant"] = (
144
+ "unknown"
145
+ )
146
+
147
+ self.archive: MessageArchive = MessageArchive(str(self.jid), self.user)
148
+ self._user_nick: Optional[str] = None
149
+
150
+ self._participants_by_nicknames = dict[str, LegacyParticipantType]()
151
+ self._participants_by_escaped_nicknames = dict[str, LegacyParticipantType]()
152
+ self._participants_by_contacts = dict["LegacyContact", LegacyParticipantType]()
153
+
154
+ self.__participants_filled = False
155
+ self.__history_filled = False
156
+ self._description = ""
157
+ super().__init__()
158
+
159
+ def __repr__(self):
160
+ return f"<MUC {self.legacy_id}/{self.jid}/{self.name}>"
161
+
162
+ def __send_configuration_change(self, codes):
163
+ part = self.get_system_participant()
164
+ part.send_configuration_change(codes)
165
+
166
+ @property
167
+ def user_nick(self):
168
+ return (
169
+ self._user_nick
170
+ or self.session.bookmarks.user_nick
171
+ or self.session.user.jid.node
172
+ )
173
+
174
+ @user_nick.setter
175
+ def user_nick(self, nick: str):
176
+ self._user_nick = nick
177
+
178
+ async def __fill_participants(self):
179
+ async with self.lock("fill participants"):
180
+ if self.__participants_filled:
181
+ return
182
+ self.__participants_filled = True
183
+ try:
184
+ await self.fill_participants()
185
+ except NotImplementedError:
186
+ pass
187
+
188
+ async def __fill_history(self):
189
+ async with self.lock("fill history"):
190
+ if self.__history_filled:
191
+ log.debug("History has already been fetched %s", self)
192
+ return
193
+ log.debug("Fetching history for %s", self)
194
+ for msg in self.archive:
195
+ try:
196
+ legacy_id = self.session.xmpp_to_legacy_msg_id(msg.id)
197
+ oldest_date = msg.when
198
+ except Exception as e:
199
+ # not all archived stanzas have a valid legacy msg ID, eg
200
+ # reactions, corrections, message with multiple attachments…
201
+ self.log.debug(f"Could not convert during history back-filling {e}")
202
+ else:
203
+ break
204
+ else:
205
+ legacy_id = None
206
+ oldest_date = None
207
+ try:
208
+ await self.backfill(legacy_id, oldest_date)
209
+ except NotImplementedError:
210
+ return
211
+ self.__history_filled = True
212
+
213
+ @property
214
+ def name(self):
215
+ return self.DISCO_NAME
216
+
217
+ @name.setter
218
+ def name(self, n: str):
219
+ if self.DISCO_NAME == n:
220
+ return
221
+ self.DISCO_NAME = n
222
+ self.__send_configuration_change((104,))
223
+
224
+ @property
225
+ def description(self):
226
+ return self._description
227
+
228
+ @description.setter
229
+ def description(self, d: str):
230
+ if self._description == d:
231
+ return
232
+ self._description = d
233
+ self.__send_configuration_change((104,))
234
+
235
+ def _on_presence_unavailable(self, p: Presence):
236
+ pto = p.get_to()
237
+ if pto.bare != self.jid.bare:
238
+ return
239
+
240
+ pfrom = p.get_from()
241
+ if pfrom.bare != self.user.bare_jid:
242
+ return
243
+ if (resource := pfrom.resource) in (resources := self.user_resources):
244
+ if pto.resource != self.user_nick:
245
+ self.log.debug(
246
+ "Received 'leave group' request but with wrong nickname. %s", p
247
+ )
248
+ resources.remove(resource)
249
+ else:
250
+ self.log.debug(
251
+ "Received 'leave group' request but resource was not listed. %s", p
252
+ )
253
+
254
+ async def update_info(self):
255
+ """
256
+ Fetch information about this group from the legacy network
257
+
258
+ This is awaited on MUC instantiation, and should be overridden to
259
+ update the attributes of the group chat, like title, subject, number
260
+ of participants etc.
261
+
262
+ To take advantage of the slidge avatar cache, you can check the .avatar
263
+ property to retrieve the "legacy file ID" of the cached avatar. If there
264
+ is no change, you should not call
265
+ :py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar()` or
266
+ attempt to modify
267
+ the :attr:.avatar property.
268
+ """
269
+ raise NotImplementedError
270
+
271
+ async def backfill(
272
+ self,
273
+ oldest_message_id: Optional[LegacyMessageType] = None,
274
+ oldest_message_date: Optional[datetime] = None,
275
+ ):
276
+ """
277
+ Override this if the legacy network provide server-side archive.
278
+ In it, send history messages using ``self.get_participant().send*``,
279
+ with the ``archive_only=True`` kwarg.
280
+
281
+ You only need to fetch messages older than ``oldest_message_id``.
282
+
283
+ :param oldest_message_id: The oldest message ID already present in the archive
284
+ :param oldest_message_date: The oldest message date already present in the archive
285
+ """
286
+ raise NotImplementedError
287
+
288
+ async def fill_participants(self):
289
+ """
290
+ In here, call self.get_participant(), self.get_participant_by_contact(),
291
+ of self.get_user_participant() to make an initial list of participants.
292
+ """
293
+ raise NotImplementedError
294
+
295
+ @property
296
+ def subject(self):
297
+ return self._subject
298
+
299
+ @subject.setter
300
+ def subject(self, s: str):
301
+ if s == self._subject:
302
+ return
303
+ self.xmpp.loop.create_task(
304
+ self.__get_subject_setter_participant()
305
+ ).add_done_callback(
306
+ lambda task: task.result().set_room_subject(
307
+ s, None, self.subject_date, False
308
+ )
309
+ )
310
+ self._subject = s
311
+
312
+ @property
313
+ def is_anonymous(self):
314
+ return self.type == MucType.CHANNEL
315
+
316
+ async def __get_subject_setter_participant(self):
317
+ from slidge.contact import LegacyContact
318
+
319
+ from .participant import LegacyParticipant
320
+
321
+ who = self.subject_setter
322
+
323
+ if isinstance(who, LegacyParticipant):
324
+ return who
325
+ elif isinstance(who, str):
326
+ return await self.get_participant(who, store=False)
327
+ elif isinstance(self.subject_setter, LegacyContact):
328
+ return await self.get_participant_by_contact(who)
329
+ else:
330
+ return self.get_system_participant()
331
+
332
+ def features(self):
333
+ features = [
334
+ "http://jabber.org/protocol/muc",
335
+ "http://jabber.org/protocol/muc#stable_id",
336
+ "http://jabber.org/protocol/muc#self-ping-optimization",
337
+ "urn:xmpp:mam:2",
338
+ "urn:xmpp:mam:2#extended",
339
+ "urn:xmpp:sid:0",
340
+ "muc_persistent",
341
+ "vcard-temp",
342
+ "urn:xmpp:ping",
343
+ "urn:xmpp:occupant-id:0",
344
+ self.xmpp.plugin["xep_0425"].stanza.NS,
345
+ ]
346
+ if self.type == MucType.GROUP:
347
+ features.extend(["muc_membersonly", "muc_nonanonymous", "muc_hidden"])
348
+ elif self.type == MucType.CHANNEL:
349
+ features.extend(["muc_open", "muc_semianonymous", "muc_public"])
350
+ elif self.type == MucType.CHANNEL_NON_ANONYMOUS:
351
+ features.extend(["muc_open", "muc_nonanonymous", "muc_public"])
352
+ return features
353
+
354
+ async def extended_features(self):
355
+ is_group = self.type == MucType.GROUP
356
+
357
+ form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
358
+
359
+ form.add_field(
360
+ "FORM_TYPE", "hidden", value="http://jabber.org/protocol/muc#roominfo"
361
+ )
362
+ form.add_field("muc#roomconfig_persistentroom", "boolean", value=True)
363
+ form.add_field("muc#roomconfig_changesubject", "boolean", value=False)
364
+ form.add_field("muc#maxhistoryfetch", value=str(self.max_history_fetch))
365
+ form.add_field("muc#roominfo_subjectmod", "boolean", value=False)
366
+
367
+ if self._ALL_INFO_FILLED_ON_STARTUP or self.__participants_filled:
368
+ n: Optional[int] = len(await self.get_participants())
369
+ else:
370
+ n = self.n_participants
371
+ if n is not None:
372
+ form.add_field("muc#roominfo_occupants", value=str(n))
373
+
374
+ if d := self.description:
375
+ form.add_field("muc#roominfo_description", value=d)
376
+
377
+ if s := self.subject:
378
+ form.add_field("muc#roominfo_subject", value=s)
379
+
380
+ if self._set_avatar_task:
381
+ await self._set_avatar_task
382
+ avatar = self.get_avatar()
383
+ if avatar and (h := avatar.id):
384
+ form.add_field(
385
+ "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1", value=h
386
+ )
387
+ form.add_field("muc#roominfo_avatarhash", "text-multi", value=[h])
388
+
389
+ form.add_field("muc#roomconfig_membersonly", "boolean", value=is_group)
390
+ form.add_field(
391
+ "muc#roomconfig_whois",
392
+ "list-single",
393
+ value="moderators" if self.is_anonymous else "anyone",
394
+ )
395
+ form.add_field("muc#roomconfig_publicroom", "boolean", value=not is_group)
396
+ form.add_field("muc#roomconfig_allowpm", "boolean", value=False)
397
+
398
+ r = [form]
399
+
400
+ if reaction_form := await self.restricted_emoji_extended_feature():
401
+ r.append(reaction_form)
402
+
403
+ return r
404
+
405
+ def shutdown(self):
406
+ user_jid = copy(self.jid)
407
+ user_jid.resource = self.user_nick
408
+ for user_full_jid in self.user_full_jids():
409
+ presence = self.xmpp.make_presence(
410
+ pfrom=user_jid, pto=user_full_jid, ptype="unavailable"
411
+ )
412
+ presence["muc"]["affiliation"] = "none"
413
+ presence["muc"]["role"] = "none"
414
+ presence["muc"]["status_codes"] = {110, 332}
415
+ presence.send()
416
+
417
+ def user_full_jids(self):
418
+ for r in self.user_resources:
419
+ j = copy(self.user.jid)
420
+ j.resource = r
421
+ yield j
422
+
423
+ @property
424
+ def user_muc_jid(self):
425
+ user_muc_jid = copy(self.jid)
426
+ user_muc_jid.resource = self.user_nick
427
+ return user_muc_jid
428
+
429
+ def _legacy_to_xmpp(self, legacy_id: LegacyMessageType):
430
+ return self.session.sent.get(legacy_id) or self.session.legacy_to_xmpp_msg_id(
431
+ legacy_id
432
+ )
433
+
434
+ async def echo(
435
+ self, msg: Message, legacy_msg_id: Optional[LegacyMessageType] = None
436
+ ):
437
+ origin_id = msg.get_origin_id()
438
+
439
+ msg.set_from(self.user_muc_jid)
440
+ msg.set_id(msg.get_id())
441
+ if origin_id:
442
+ # because of slixmpp internal magic, we need to do this to ensure the origin_id
443
+ # is present
444
+ set_origin_id(msg, origin_id)
445
+ if legacy_msg_id:
446
+ msg["stanza_id"]["id"] = self.session.legacy_to_xmpp_msg_id(legacy_msg_id)
447
+ else:
448
+ msg["stanza_id"]["id"] = str(uuid4())
449
+ msg["stanza_id"]["by"] = self.jid
450
+ msg["occupant-id"]["id"] = "slidge-user"
451
+
452
+ self.archive.add(msg, await self.get_user_participant())
453
+
454
+ for user_full_jid in self.user_full_jids():
455
+ self.log.debug("Echoing to %s", user_full_jid)
456
+ msg = copy(msg)
457
+ msg.set_to(user_full_jid)
458
+
459
+ msg.send()
460
+
461
+ def _post_avatar_update(self) -> None:
462
+ self.__send_configuration_change((104,))
463
+ self._send_room_presence()
464
+
465
+ def _send_room_presence(self, user_full_jid: Optional[JID] = None):
466
+ if user_full_jid is None:
467
+ tos = self.user_full_jids()
468
+ else:
469
+ tos = [user_full_jid]
470
+ for to in tos:
471
+ p = self.xmpp.make_presence(pfrom=self.jid, pto=to)
472
+ if (avatar := self.get_avatar()) and (h := avatar.id):
473
+ p["vcard_temp_update"]["photo"] = h
474
+ else:
475
+ p["vcard_temp_update"]["photo"] = ""
476
+ p.send()
477
+
478
+ async def join(self, join_presence: Presence):
479
+ user_full_jid = join_presence.get_from()
480
+ requested_nickname = join_presence.get_to().resource
481
+ client_resource = user_full_jid.resource
482
+
483
+ if client_resource in self.user_resources:
484
+ self.log.debug("Received join from a resource that is already joined.")
485
+
486
+ self.user_resources.add(client_resource)
487
+
488
+ if not requested_nickname or not client_resource:
489
+ raise XMPPError("jid-malformed", by=self.jid)
490
+
491
+ self.log.debug(
492
+ "Resource %s of %s wants to join room %s with nickname %s",
493
+ client_resource,
494
+ self.user,
495
+ self.legacy_id,
496
+ requested_nickname,
497
+ )
498
+
499
+ await self.__fill_participants()
500
+
501
+ for participant in self._participants_by_nicknames.values():
502
+ if participant.is_user: # type:ignore
503
+ continue
504
+ if participant.is_system: # type:ignore
505
+ continue
506
+ participant.send_initial_presence(full_jid=user_full_jid)
507
+
508
+ user_nick = self.user_nick
509
+ user_participant = await self.get_user_participant()
510
+ if not user_participant.is_user: # type:ignore
511
+ self.log.warning("is_user flag not set participant on user_participant")
512
+ user_participant.is_user = True # type:ignore
513
+ user_participant.send_initial_presence(
514
+ user_full_jid,
515
+ presence_id=join_presence["id"],
516
+ nick_change=user_nick != requested_nickname,
517
+ )
518
+
519
+ history_params = join_presence["muc_join"]["history"]
520
+ maxchars = int_or_none(history_params["maxchars"])
521
+ maxstanzas = int_or_none(history_params["maxstanzas"])
522
+ seconds = int_or_none(history_params["seconds"])
523
+ try:
524
+ since = self.xmpp.plugin["xep_0082"].parse(history_params["since"])
525
+ except ValueError:
526
+ since = None
527
+ if seconds:
528
+ since = datetime.now() - timedelta(seconds=seconds)
529
+ if equals_zero(maxchars) or equals_zero(maxstanzas):
530
+ log.debug("Joining client does not want any old-school MUC history-on-join")
531
+ else:
532
+ self.log.debug("Old school history fill")
533
+ await self.__fill_history()
534
+ await self.__old_school_history(
535
+ user_full_jid,
536
+ maxchars=maxchars,
537
+ maxstanzas=maxstanzas,
538
+ since=since,
539
+ )
540
+ (await self.__get_subject_setter_participant()).set_room_subject(
541
+ self._subject if self.HAS_SUBJECT else (self.description or self.name),
542
+ user_full_jid,
543
+ self.subject_date,
544
+ )
545
+ if t := self._set_avatar_task:
546
+ await t
547
+ self._send_room_presence(user_full_jid)
548
+ self.user_resources.add(client_resource)
549
+
550
+ async def get_user_participant(self, **kwargs) -> "LegacyParticipantType":
551
+ """
552
+ Get the participant representing the gateway user
553
+
554
+ :param kwargs: additional parameters for the :class:`.Participant`
555
+ construction (optional)
556
+ :return:
557
+ """
558
+ p = await self.get_participant(self.user_nick, is_user=True, **kwargs)
559
+ self.__store_participant(p)
560
+ return p
561
+
562
+ def __store_participant(self, p: "LegacyParticipantType"):
563
+ # we don't want to update the participant list when we're filling history
564
+ if not self.KEEP_BACKFILLED_PARTICIPANTS and self.get_lock("fill history"):
565
+ return
566
+ self._participants_by_nicknames[p.nickname] = p # type:ignore
567
+ if p.contact:
568
+ self._participants_by_contacts[p.contact] = p
569
+
570
+ async def get_participant(
571
+ self,
572
+ nickname: str,
573
+ raise_if_not_found=False,
574
+ fill_first=False,
575
+ store=True,
576
+ **kwargs,
577
+ ) -> "LegacyParticipantType":
578
+ """
579
+ Get a participant by their nickname.
580
+
581
+ In non-anonymous groups, you probably want to use
582
+ :meth:`.LegacyMUC.get_participant_by_contact` instead.
583
+
584
+ :param nickname: Nickname of the participant (used as resource part in the MUC)
585
+ :param raise_if_not_found: Raise XMPPError("item-not-found") if they are not
586
+ in the participant list (internal use by slidge, plugins should not
587
+ need that)
588
+ :param fill_first: Ensure :meth:`.LegacyMUC.fill_participants()` has been called first
589
+ (internal use by slidge, plugins should not need that)
590
+ :param store: persistently store the user in the list of MUC participants
591
+ :param kwargs: additional parameters for the :class:`.Participant`
592
+ construction (optional)
593
+ :return:
594
+ """
595
+ if fill_first:
596
+ await self.__fill_participants()
597
+ p = self._participants_by_nicknames.get(
598
+ nickname
599
+ ) or self._participants_by_escaped_nicknames.get(nickname)
600
+ if p is None:
601
+ if raise_if_not_found:
602
+ raise XMPPError("item-not-found")
603
+ p = self.Participant(self, nickname, **kwargs)
604
+ if store:
605
+ self.__store_participant(p)
606
+ return p
607
+
608
+ def get_system_participant(self) -> "LegacyParticipantType":
609
+ """
610
+ Get a pseudo-participant, representing the room itself
611
+
612
+ Can be useful for events that cannot be mapped to a participant,
613
+ e.g. anonymous moderation events, or announces from the legacy
614
+ service
615
+ :return:
616
+ """
617
+ return self.Participant(self, is_system=True)
618
+
619
+ async def get_participant_by_contact(
620
+ self, c: "LegacyContact", **kwargs
621
+ ) -> "LegacyParticipantType":
622
+ """
623
+ Get a non-anonymous participant.
624
+
625
+ This is what should be used in non-anonymous groups ideally, to ensure
626
+ that the Contact jid is associated to this participant
627
+
628
+ :param c: The :class:`.LegacyContact` instance corresponding to this contact
629
+ :param kwargs: additional parameters for the :class:`.Participant`
630
+ construction (optional)
631
+ :return:
632
+ """
633
+ await self.session.contacts.ready
634
+ p = self._participants_by_contacts.get(c)
635
+ if p is None:
636
+ nickname = c.name or _unescape_node(c.jid_username)
637
+ if nickname in self._participants_by_nicknames:
638
+ self.log.debug("Nickname conflict")
639
+ nickname = f"{nickname} ({c.jid_username})"
640
+ p = self.Participant(self, nickname, **kwargs)
641
+ p.contact = c
642
+ c.participants.add(p)
643
+ # FIXME: this is not great but given the current design,
644
+ # during participants fill and history backfill we do not
645
+ # want to send presence, because we might update affiliation
646
+ # and role afterwards.
647
+ # We need a refactor of the MUC class… later™
648
+ if not self.get_lock("fill participants") and not self.get_lock(
649
+ "fill history"
650
+ ):
651
+ p.send_last_presence(force=True, no_cache_online=True)
652
+ self.__store_participant(p)
653
+ return p
654
+
655
+ async def get_participant_by_legacy_id(
656
+ self, legacy_id: LegacyUserIdType, **kwargs
657
+ ) -> "LegacyParticipantType":
658
+ try:
659
+ c = await self.session.contacts.by_legacy_id(legacy_id)
660
+ except ContactIsUser:
661
+ return await self.get_user_participant(**kwargs)
662
+ return await self.get_participant_by_contact(c, **kwargs)
663
+
664
+ async def get_participants(self):
665
+ """
666
+ Get all known participants of the group, ensure :meth:`.LegacyMUC.fill_participants`
667
+ has been awaited once before. Plugins should not use that, internal
668
+ slidge use only.
669
+ :return:
670
+ """
671
+ await self.__fill_participants()
672
+ return list(self._participants_by_nicknames.values())
673
+
674
+ def remove_participant(self, p: "LegacyParticipantType", kick=False, ban=False):
675
+ """
676
+ Call this when a participant leaves the room
677
+
678
+ :param p: The participant
679
+ :param kick: Whether the participant left because they were kicked
680
+ :param ban: Whether the participant left because they were banned
681
+ """
682
+ if kick and ban:
683
+ raise TypeError("Either kick or ban")
684
+ if p.contact is not None:
685
+ try:
686
+ del self._participants_by_contacts[p.contact]
687
+ except KeyError:
688
+ self.log.warning(
689
+ "Removed a participant we didn't know was here?, %s", p
690
+ )
691
+ else:
692
+ p.contact.participants.remove(p)
693
+ try:
694
+ del self._participants_by_nicknames[p.nickname] # type:ignore
695
+ except KeyError:
696
+ self.log.warning("Removed a participant we didn't know was here?, %s", p)
697
+ if kick:
698
+ codes = {307}
699
+ elif ban:
700
+ codes = {301}
701
+ else:
702
+ codes = None
703
+ presence = p._make_presence(ptype="unavailable", status_codes=codes)
704
+ p._affiliation = "outcast" if ban else "none"
705
+ p._role = "none"
706
+ p._send(presence)
707
+
708
+ def rename_participant(self, old_nickname: str, new_nickname: str):
709
+ try:
710
+ p = self._participants_by_nicknames.pop(old_nickname)
711
+ except KeyError:
712
+ # when called by participant.nickname.setter
713
+ return
714
+ self._participants_by_nicknames[new_nickname] = p
715
+ if p.nickname == old_nickname:
716
+ p.nickname = new_nickname
717
+
718
+ async def __old_school_history(
719
+ self,
720
+ full_jid: JID,
721
+ maxchars: Optional[int] = None,
722
+ maxstanzas: Optional[int] = None,
723
+ seconds: Optional[int] = None,
724
+ since: Optional[datetime] = None,
725
+ ):
726
+ """
727
+ Old-style history join (internal slidge use)
728
+
729
+ :param full_jid:
730
+ :param maxchars:
731
+ :param maxstanzas:
732
+ :param seconds:
733
+ :param since:
734
+ :return:
735
+ """
736
+ if since is None:
737
+ if seconds is None:
738
+ start_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
739
+ else:
740
+ start_date = datetime.now(tz=timezone.utc) - timedelta(seconds=seconds)
741
+ else:
742
+ start_date = since or datetime.now(tz=timezone.utc) - timedelta(days=1)
743
+
744
+ for h_msg in self.archive.get_all(
745
+ start_date=start_date, end_date=None, last_page_n=maxstanzas
746
+ ):
747
+ msg = h_msg.stanza_component_ns
748
+ msg["delay"]["stamp"] = h_msg.when
749
+ msg.set_to(full_jid)
750
+ self.xmpp.send(msg, False)
751
+
752
+ async def send_mam(self, iq: Iq):
753
+ await self.__fill_history()
754
+
755
+ form_values = iq["mam"]["form"].get_values()
756
+
757
+ start_date = str_to_datetime_or_none(form_values.get("start"))
758
+ end_date = str_to_datetime_or_none(form_values.get("end"))
759
+
760
+ after_id = form_values.get("after-id")
761
+ before_id = form_values.get("before-id")
762
+
763
+ sender = form_values.get("with")
764
+
765
+ ids = form_values.get("ids") or ()
766
+
767
+ if max_str := iq["mam"]["rsm"]["max"]:
768
+ try:
769
+ max_results = int(max_str)
770
+ except ValueError:
771
+ max_results = None
772
+ else:
773
+ max_results = None
774
+
775
+ after_id_rsm = iq["mam"]["rsm"]["after"]
776
+ after_id = after_id_rsm or after_id
777
+
778
+ before_rsm = iq["mam"]["rsm"]["before"]
779
+ if before_rsm is True and max_results is not None:
780
+ last_page_n = max_results
781
+ else:
782
+ last_page_n = None
783
+
784
+ first = None
785
+ last = None
786
+ count = 0
787
+
788
+ it = self.archive.get_all(
789
+ start_date,
790
+ end_date,
791
+ before_id,
792
+ after_id,
793
+ ids,
794
+ last_page_n,
795
+ sender,
796
+ bool(iq["mam"]["flip_page"]),
797
+ )
798
+
799
+ for history_msg in it:
800
+ last = xmpp_id = history_msg.id
801
+ if first is None:
802
+ first = xmpp_id
803
+
804
+ wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from())
805
+ wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"]
806
+ wrapper_msg["mam_result"]["id"] = xmpp_id
807
+ wrapper_msg["mam_result"].append(history_msg.forwarded())
808
+
809
+ wrapper_msg.send()
810
+ count += 1
811
+
812
+ if max_results and count == max_results:
813
+ break
814
+
815
+ if max_results:
816
+ try:
817
+ next(it)
818
+ except StopIteration:
819
+ complete = True
820
+ else:
821
+ complete = False
822
+ else:
823
+ complete = True
824
+
825
+ reply = iq.reply()
826
+ if not self.STABLE_ARCHIVE:
827
+ reply["mam_fin"]["stable"] = "false"
828
+ if complete:
829
+ reply["mam_fin"]["complete"] = "true"
830
+ reply["mam_fin"]["rsm"]["first"] = first
831
+ reply["mam_fin"]["rsm"]["last"] = last
832
+ reply["mam_fin"]["rsm"]["count"] = str(count)
833
+ reply.send()
834
+
835
+ async def send_mam_metadata(self, iq: Iq):
836
+ await self.__fill_history()
837
+ await self.archive.send_metadata(iq)
838
+
839
+ async def kick_resource(self, r: str):
840
+ """
841
+ Kick a XMPP client of the user. (slidge internal use)
842
+
843
+ :param r: The resource to kick
844
+ """
845
+ pto = self.user.jid
846
+ pto.resource = r
847
+ p = self.xmpp.make_presence(
848
+ pfrom=(await self.get_user_participant()).jid, pto=pto
849
+ )
850
+ p["muc"]["affiliation"] = "none"
851
+ p["muc"]["role"] = "none"
852
+ p["muc"]["status_codes"] = {110, 333}
853
+ p.send()
854
+
855
+ async def add_to_bookmarks(self, auto_join=True, invite=False, preserve=True):
856
+ """
857
+ Add the MUC to the user's XMPP bookmarks (:xep:`0402')
858
+
859
+ This requires that slidge has the IQ privileged set correctly
860
+ on the XMPP server
861
+
862
+ :param auto_join: whether XMPP clients should automatically join
863
+ this MUC on startup. In theory, XMPP clients will receive
864
+ a "push" notification when this is called, and they will
865
+ join if they are online.
866
+ :param invite: send an invitation to join this MUC emanating from
867
+ the gateway. While this should not be strictly necessary,
868
+ it can help for clients that do not support :xep:`0402`, or
869
+ that have 'do not honor bookmarks auto-join' turned on in their
870
+ settings.
871
+ :param preserve: preserve auto-join and bookmarks extensions
872
+ set by the user outside slidge
873
+ """
874
+ item = Item()
875
+ item["id"] = self.jid
876
+
877
+ iq = Iq(stype="get", sfrom=self.user.jid, sto=self.user.jid)
878
+ iq["pubsub"]["items"]["node"] = self.xmpp["xep_0402"].stanza.NS
879
+ iq["pubsub"]["items"].append(item)
880
+
881
+ is_update = False
882
+ if preserve:
883
+ try:
884
+ ans = await self.xmpp["xep_0356"].send_privileged_iq(iq)
885
+ is_update = len(ans["pubsub"]["items"]) == 1
886
+ # this below creates the item if it wasn't here already
887
+ # (slixmpp annoying magic)
888
+ item = ans["pubsub"]["items"]["item"]
889
+ item["id"] = self.jid
890
+ except IqError:
891
+ item["conference"]["autojoin"] = auto_join
892
+ except PermissionError:
893
+ warnings.warn(
894
+ "IQ privileges (XEP0356) are not set, we cannot fetch the user bookmarks"
895
+ )
896
+ else:
897
+ # if the bookmark is already present, we preserve it as much as
898
+ # possible, especially custom <extensions>
899
+ self.log.debug("Existing: %s", item)
900
+ # if it's an update, we do not touch the auto join flag
901
+ if not is_update:
902
+ item["conference"]["autojoin"] = auto_join
903
+ else:
904
+ item["conference"]["autojoin"] = auto_join
905
+
906
+ item["conference"]["nick"] = self.user_nick
907
+ iq = Iq(stype="set", sfrom=self.user.jid, sto=self.user.jid)
908
+ iq["pubsub"]["publish"]["node"] = self.xmpp["xep_0402"].stanza.NS
909
+ iq["pubsub"]["publish"].append(item)
910
+
911
+ iq["pubsub"]["publish_options"] = _BOOKMARKS_OPTIONS
912
+
913
+ try:
914
+ await self.xmpp["xep_0356"].send_privileged_iq(iq)
915
+ except PermissionError:
916
+ warnings.warn(
917
+ "IQ privileges (XEP0356) are not set, we cannot add bookmarks for the user"
918
+ )
919
+ # fallback by forcing invitation
920
+ invite = True
921
+ except IqError as e:
922
+ warnings.warn(
923
+ f"Something went wrong while trying to set the bookmarks: {e}"
924
+ )
925
+ # fallback by forcing invitation
926
+ invite = True
927
+
928
+ if invite or (config.ALWAYS_INVITE_WHEN_ADDING_BOOKMARKS and not is_update):
929
+ self.session.send_gateway_invite(
930
+ self, reason="This group could not be added automatically for you"
931
+ )
932
+
933
+ async def on_avatar(
934
+ self, data: Optional[bytes], mime: Optional[str]
935
+ ) -> Optional[Union[int, str]]:
936
+ """
937
+ Called when the user tries to set the avatar of the room from an XMPP
938
+ client.
939
+
940
+ If the set avatar operation is completed, should return a legacy image
941
+ unique identifier. In this case the MUC avatar will be immediately
942
+ updated on the XMPP side.
943
+
944
+ If data is not None and this method returns None, then we assume that
945
+ self.set_avatar() will be called elsewhere, eg triggered by a legacy
946
+ room update event.
947
+
948
+ :param data: image data or None if the user meant to remove the avatar
949
+ :param mime: the mime type of the image. Since this is provided by
950
+ the XMPP client, there is no guarantee that this is valid or
951
+ correct.
952
+ :return: A unique avatar identifier, which will trigger
953
+ :py:meth:`slidge.group.room.LegacyMUC.set_avatar`. Alternatively, None, if
954
+ :py:meth:`.LegacyMUC.set_avatar` is meant to be awaited somewhere else.
955
+ """
956
+ raise NotImplementedError
957
+
958
+ admin_set_avatar = deprecated("LegacyMUC.on_avatar", on_avatar)
959
+
960
+ async def on_set_affiliation(
961
+ self,
962
+ contact: "LegacyContact",
963
+ affiliation: MucAffiliation,
964
+ reason: Optional[str],
965
+ nickname: Optional[str],
966
+ ):
967
+ """
968
+ Triggered when the user requests changing the affiliation of a contact
969
+ for this group,
970
+
971
+ Examples: promotion them to moderator, kick (affiliation=none),
972
+ ban (affiliation=outcast).
973
+
974
+ :param contact: The contact whose affiliation change is requested
975
+ :param affiliation: The new affiliation
976
+ :param reason: A reason for this affiliation change
977
+ :param nickname:
978
+ """
979
+ raise NotImplementedError
980
+
981
+ async def on_set_config(
982
+ self,
983
+ name: Optional[str],
984
+ description: Optional[str],
985
+ ):
986
+ """
987
+ Triggered when the user requests changing the room configuration.
988
+ Only title and description can be changed at the moment.
989
+
990
+ The legacy module is responsible for updating :attr:`.title` and/or
991
+ :attr:`.description` of this instance.
992
+
993
+ If :attr:`.HAS_DESCRIPTION` is set to False, description will always
994
+ be ``None``.
995
+
996
+ :param name: The new name of the room.
997
+ :param description: The new description of the room.
998
+ """
999
+ raise NotImplementedError
1000
+
1001
+ async def on_destroy_request(self, reason: Optional[str]):
1002
+ """
1003
+ Triggered when the user requests room destruction.
1004
+
1005
+ :param reason: Optionally, a reason for the destruction
1006
+ """
1007
+ raise NotImplementedError
1008
+
1009
+ async def parse_mentions(self, text: str) -> list[Mention]:
1010
+ await self.__fill_participants()
1011
+
1012
+ if len(self._participants_by_nicknames) == 0:
1013
+ return []
1014
+
1015
+ result = []
1016
+ for match in re.finditer(
1017
+ "|".join(
1018
+ sorted(
1019
+ [re.escape(nick) for nick in self._participants_by_nicknames],
1020
+ key=lambda nick: len(nick),
1021
+ reverse=True,
1022
+ )
1023
+ ),
1024
+ text,
1025
+ ):
1026
+ span = match.span()
1027
+ nick = match.group()
1028
+ if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION:
1029
+ continue
1030
+ if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION:
1031
+ participant = self._participants_by_nicknames[nick]
1032
+ if contact := participant.contact:
1033
+ result.append(Mention(contact=contact, start=span[0], end=span[1]))
1034
+ return result
1035
+
1036
+ async def on_set_subject(self, subject: str) -> None:
1037
+ """
1038
+ Triggered when the user requests changing the room subject.
1039
+
1040
+ The legacy module is responsible for updating :attr:`.subject` of this
1041
+ instance.
1042
+
1043
+ :param subject: The new subject for this room.
1044
+ """
1045
+ raise NotImplementedError
1046
+
1047
+
1048
+ def set_origin_id(msg: Message, origin_id: str):
1049
+ sub = ET.Element("{urn:xmpp:sid:0}origin-id")
1050
+ sub.attrib["id"] = origin_id
1051
+ msg.xml.append(sub)
1052
+
1053
+
1054
+ def int_or_none(x):
1055
+ try:
1056
+ return int(x)
1057
+ except ValueError:
1058
+ return None
1059
+
1060
+
1061
+ def equals_zero(x):
1062
+ if x is None:
1063
+ return False
1064
+ else:
1065
+ return x == 0
1066
+
1067
+
1068
+ def str_to_datetime_or_none(date: Optional[str]):
1069
+ if date is None:
1070
+ return
1071
+ try:
1072
+ return str_to_datetime(date)
1073
+ except ValueError:
1074
+ return None
1075
+
1076
+
1077
+ def bookmarks_form():
1078
+ form = Form()
1079
+ form["type"] = "submit"
1080
+ form.add_field(
1081
+ "FORM_TYPE",
1082
+ value="http://jabber.org/protocol/pubsub#publish-options",
1083
+ ftype="hidden",
1084
+ )
1085
+ form.add_field("pubsub#persist_items", value="1")
1086
+ form.add_field("pubsub#max_items", value="max")
1087
+ form.add_field("pubsub#send_last_published_item", value="never")
1088
+ form.add_field("pubsub#access_model", value="whitelist")
1089
+ return form
1090
+
1091
+
1092
+ _BOOKMARKS_OPTIONS = bookmarks_form()
1093
+ _WHITESPACE_OR_PUNCTUATION = string.whitespace + string.punctuation
1094
+
1095
+ log = logging.getLogger(__name__)