slidge 0.1.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
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__)