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
slidge/group/room.py ADDED
@@ -0,0 +1,1103 @@
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
+ self.get_system_participant()
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
+
549
+ async def get_user_participant(self, **kwargs) -> "LegacyParticipantType":
550
+ """
551
+ Get the participant representing the gateway user
552
+
553
+ :param kwargs: additional parameters for the :class:`.Participant`
554
+ construction (optional)
555
+ :return:
556
+ """
557
+ p = await self.get_participant(self.user_nick, is_user=True, **kwargs)
558
+ self.__store_participant(p)
559
+ return p
560
+
561
+ def __store_participant(self, p: "LegacyParticipantType"):
562
+ # we don't want to update the participant list when we're filling history
563
+ if not self.KEEP_BACKFILLED_PARTICIPANTS and self.get_lock("fill history"):
564
+ return
565
+ self._participants_by_nicknames[p.nickname] = p # type:ignore
566
+ if p.contact:
567
+ self._participants_by_contacts[p.contact] = p
568
+
569
+ async def get_participant(
570
+ self,
571
+ nickname: str,
572
+ raise_if_not_found=False,
573
+ fill_first=False,
574
+ store=True,
575
+ **kwargs,
576
+ ) -> "LegacyParticipantType":
577
+ """
578
+ Get a participant by their nickname.
579
+
580
+ In non-anonymous groups, you probably want to use
581
+ :meth:`.LegacyMUC.get_participant_by_contact` instead.
582
+
583
+ :param nickname: Nickname of the participant (used as resource part in the MUC)
584
+ :param raise_if_not_found: Raise XMPPError("item-not-found") if they are not
585
+ in the participant list (internal use by slidge, plugins should not
586
+ need that)
587
+ :param fill_first: Ensure :meth:`.LegacyMUC.fill_participants()` has been called first
588
+ (internal use by slidge, plugins should not need that)
589
+ :param store: persistently store the user in the list of MUC participants
590
+ :param kwargs: additional parameters for the :class:`.Participant`
591
+ construction (optional)
592
+ :return:
593
+ """
594
+ if fill_first:
595
+ await self.__fill_participants()
596
+ p = self._participants_by_nicknames.get(
597
+ nickname
598
+ ) or self._participants_by_escaped_nicknames.get(nickname)
599
+ if p is None:
600
+ if raise_if_not_found:
601
+ raise XMPPError("item-not-found")
602
+ p = self.Participant(self, nickname, **kwargs)
603
+ if store:
604
+ self.__store_participant(p)
605
+ if (
606
+ not self.get_lock("fill participants")
607
+ and not self.get_lock("fill history")
608
+ and self.__participants_filled
609
+ and not p.is_user
610
+ and not p.is_system
611
+ ):
612
+ p.send_affiliation_change()
613
+ return p
614
+
615
+ def get_system_participant(self) -> "LegacyParticipantType":
616
+ """
617
+ Get a pseudo-participant, representing the room itself
618
+
619
+ Can be useful for events that cannot be mapped to a participant,
620
+ e.g. anonymous moderation events, or announces from the legacy
621
+ service
622
+ :return:
623
+ """
624
+ return self.Participant(self, is_system=True)
625
+
626
+ async def get_participant_by_contact(
627
+ self, c: "LegacyContact", **kwargs
628
+ ) -> "LegacyParticipantType":
629
+ """
630
+ Get a non-anonymous participant.
631
+
632
+ This is what should be used in non-anonymous groups ideally, to ensure
633
+ that the Contact jid is associated to this participant
634
+
635
+ :param c: The :class:`.LegacyContact` instance corresponding to this contact
636
+ :param kwargs: additional parameters for the :class:`.Participant`
637
+ construction (optional)
638
+ :return:
639
+ """
640
+ await self.session.contacts.ready
641
+ p = self._participants_by_contacts.get(c)
642
+ if p is None:
643
+ nickname = c.name or _unescape_node(c.jid_username)
644
+ if nickname in self._participants_by_nicknames:
645
+ self.log.debug("Nickname conflict")
646
+ nickname = f"{nickname} ({c.jid_username})"
647
+ p = self.Participant(self, nickname, **kwargs)
648
+ p.contact = c
649
+ c.participants.add(p)
650
+ # FIXME: this is not great but given the current design,
651
+ # during participants fill and history backfill we do not
652
+ # want to send presence, because we might update affiliation
653
+ # and role afterwards.
654
+ # We need a refactor of the MUC class… later™
655
+ if not self.get_lock("fill participants") and not self.get_lock(
656
+ "fill history"
657
+ ):
658
+ p.send_last_presence(force=True, no_cache_online=True)
659
+ self.__store_participant(p)
660
+ return p
661
+
662
+ async def get_participant_by_legacy_id(
663
+ self, legacy_id: LegacyUserIdType, **kwargs
664
+ ) -> "LegacyParticipantType":
665
+ try:
666
+ c = await self.session.contacts.by_legacy_id(legacy_id)
667
+ except ContactIsUser:
668
+ return await self.get_user_participant(**kwargs)
669
+ return await self.get_participant_by_contact(c, **kwargs)
670
+
671
+ async def get_participants(self):
672
+ """
673
+ Get all known participants of the group, ensure :meth:`.LegacyMUC.fill_participants`
674
+ has been awaited once before. Plugins should not use that, internal
675
+ slidge use only.
676
+ :return:
677
+ """
678
+ await self.__fill_participants()
679
+ return list(self._participants_by_nicknames.values())
680
+
681
+ def remove_participant(self, p: "LegacyParticipantType", kick=False, ban=False):
682
+ """
683
+ Call this when a participant leaves the room
684
+
685
+ :param p: The participant
686
+ :param kick: Whether the participant left because they were kicked
687
+ :param ban: Whether the participant left because they were banned
688
+ """
689
+ if kick and ban:
690
+ raise TypeError("Either kick or ban")
691
+ if p.contact is not None:
692
+ try:
693
+ del self._participants_by_contacts[p.contact]
694
+ except KeyError:
695
+ self.log.warning(
696
+ "Removed a participant we didn't know was here?, %s", p
697
+ )
698
+ else:
699
+ p.contact.participants.remove(p)
700
+ try:
701
+ del self._participants_by_nicknames[p.nickname] # type:ignore
702
+ except KeyError:
703
+ self.log.warning("Removed a participant we didn't know was here?, %s", p)
704
+ if kick:
705
+ codes = {307}
706
+ elif ban:
707
+ codes = {301}
708
+ else:
709
+ codes = None
710
+ presence = p._make_presence(ptype="unavailable", status_codes=codes)
711
+ p._affiliation = "outcast" if ban else "none"
712
+ p._role = "none"
713
+ p._send(presence)
714
+
715
+ def rename_participant(self, old_nickname: str, new_nickname: str):
716
+ try:
717
+ p = self._participants_by_nicknames.pop(old_nickname)
718
+ except KeyError:
719
+ # when called by participant.nickname.setter
720
+ return
721
+ self._participants_by_nicknames[new_nickname] = p
722
+ if p.nickname == old_nickname:
723
+ p.nickname = new_nickname
724
+
725
+ async def __old_school_history(
726
+ self,
727
+ full_jid: JID,
728
+ maxchars: Optional[int] = None,
729
+ maxstanzas: Optional[int] = None,
730
+ seconds: Optional[int] = None,
731
+ since: Optional[datetime] = None,
732
+ ):
733
+ """
734
+ Old-style history join (internal slidge use)
735
+
736
+ :param full_jid:
737
+ :param maxchars:
738
+ :param maxstanzas:
739
+ :param seconds:
740
+ :param since:
741
+ :return:
742
+ """
743
+ if since is None:
744
+ if seconds is None:
745
+ start_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
746
+ else:
747
+ start_date = datetime.now(tz=timezone.utc) - timedelta(seconds=seconds)
748
+ else:
749
+ start_date = since or datetime.now(tz=timezone.utc) - timedelta(days=1)
750
+
751
+ for h_msg in self.archive.get_all(
752
+ start_date=start_date, end_date=None, last_page_n=maxstanzas
753
+ ):
754
+ msg = h_msg.stanza_component_ns
755
+ msg["delay"]["stamp"] = h_msg.when
756
+ msg.set_to(full_jid)
757
+ self.xmpp.send(msg, False)
758
+
759
+ async def send_mam(self, iq: Iq):
760
+ await self.__fill_history()
761
+
762
+ form_values = iq["mam"]["form"].get_values()
763
+
764
+ start_date = str_to_datetime_or_none(form_values.get("start"))
765
+ end_date = str_to_datetime_or_none(form_values.get("end"))
766
+
767
+ after_id = form_values.get("after-id")
768
+ before_id = form_values.get("before-id")
769
+
770
+ sender = form_values.get("with")
771
+
772
+ ids = form_values.get("ids") or ()
773
+
774
+ if max_str := iq["mam"]["rsm"]["max"]:
775
+ try:
776
+ max_results = int(max_str)
777
+ except ValueError:
778
+ max_results = None
779
+ else:
780
+ max_results = None
781
+
782
+ after_id_rsm = iq["mam"]["rsm"]["after"]
783
+ after_id = after_id_rsm or after_id
784
+
785
+ before_rsm = iq["mam"]["rsm"]["before"]
786
+ if before_rsm is True and max_results is not None:
787
+ last_page_n = max_results
788
+ else:
789
+ last_page_n = None
790
+
791
+ first = None
792
+ last = None
793
+ count = 0
794
+
795
+ it = self.archive.get_all(
796
+ start_date,
797
+ end_date,
798
+ before_id,
799
+ after_id,
800
+ ids,
801
+ last_page_n,
802
+ sender,
803
+ bool(iq["mam"]["flip_page"]),
804
+ )
805
+
806
+ for history_msg in it:
807
+ last = xmpp_id = history_msg.id
808
+ if first is None:
809
+ first = xmpp_id
810
+
811
+ wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from())
812
+ wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"]
813
+ wrapper_msg["mam_result"]["id"] = xmpp_id
814
+ wrapper_msg["mam_result"].append(history_msg.forwarded())
815
+
816
+ wrapper_msg.send()
817
+ count += 1
818
+
819
+ if max_results and count == max_results:
820
+ break
821
+
822
+ if max_results:
823
+ try:
824
+ next(it)
825
+ except StopIteration:
826
+ complete = True
827
+ else:
828
+ complete = False
829
+ else:
830
+ complete = True
831
+
832
+ reply = iq.reply()
833
+ if not self.STABLE_ARCHIVE:
834
+ reply["mam_fin"]["stable"] = "false"
835
+ if complete:
836
+ reply["mam_fin"]["complete"] = "true"
837
+ reply["mam_fin"]["rsm"]["first"] = first
838
+ reply["mam_fin"]["rsm"]["last"] = last
839
+ reply["mam_fin"]["rsm"]["count"] = str(count)
840
+ reply.send()
841
+
842
+ async def send_mam_metadata(self, iq: Iq):
843
+ await self.__fill_history()
844
+ await self.archive.send_metadata(iq)
845
+
846
+ async def kick_resource(self, r: str):
847
+ """
848
+ Kick a XMPP client of the user. (slidge internal use)
849
+
850
+ :param r: The resource to kick
851
+ """
852
+ pto = self.user.jid
853
+ pto.resource = r
854
+ p = self.xmpp.make_presence(
855
+ pfrom=(await self.get_user_participant()).jid, pto=pto
856
+ )
857
+ p["type"] = "unavailable"
858
+ p["muc"]["affiliation"] = "none"
859
+ p["muc"]["role"] = "none"
860
+ p["muc"]["status_codes"] = {110, 333}
861
+ p.send()
862
+
863
+ async def add_to_bookmarks(self, auto_join=True, invite=False, preserve=True):
864
+ """
865
+ Add the MUC to the user's XMPP bookmarks (:xep:`0402')
866
+
867
+ This requires that slidge has the IQ privileged set correctly
868
+ on the XMPP server
869
+
870
+ :param auto_join: whether XMPP clients should automatically join
871
+ this MUC on startup. In theory, XMPP clients will receive
872
+ a "push" notification when this is called, and they will
873
+ join if they are online.
874
+ :param invite: send an invitation to join this MUC emanating from
875
+ the gateway. While this should not be strictly necessary,
876
+ it can help for clients that do not support :xep:`0402`, or
877
+ that have 'do not honor bookmarks auto-join' turned on in their
878
+ settings.
879
+ :param preserve: preserve auto-join and bookmarks extensions
880
+ set by the user outside slidge
881
+ """
882
+ item = Item()
883
+ item["id"] = self.jid
884
+
885
+ iq = Iq(stype="get", sfrom=self.user.jid, sto=self.user.jid)
886
+ iq["pubsub"]["items"]["node"] = self.xmpp["xep_0402"].stanza.NS
887
+ iq["pubsub"]["items"].append(item)
888
+
889
+ is_update = False
890
+ if preserve:
891
+ try:
892
+ ans = await self.xmpp["xep_0356"].send_privileged_iq(iq)
893
+ is_update = len(ans["pubsub"]["items"]) == 1
894
+ # this below creates the item if it wasn't here already
895
+ # (slixmpp annoying magic)
896
+ item = ans["pubsub"]["items"]["item"]
897
+ item["id"] = self.jid
898
+ except IqError:
899
+ item["conference"]["autojoin"] = auto_join
900
+ except PermissionError:
901
+ warnings.warn(
902
+ "IQ privileges (XEP0356) are not set, we cannot fetch the user bookmarks"
903
+ )
904
+ else:
905
+ # if the bookmark is already present, we preserve it as much as
906
+ # possible, especially custom <extensions>
907
+ self.log.debug("Existing: %s", item)
908
+ # if it's an update, we do not touch the auto join flag
909
+ if not is_update:
910
+ item["conference"]["autojoin"] = auto_join
911
+ else:
912
+ item["conference"]["autojoin"] = auto_join
913
+
914
+ item["conference"]["nick"] = self.user_nick
915
+ iq = Iq(stype="set", sfrom=self.user.jid, sto=self.user.jid)
916
+ iq["pubsub"]["publish"]["node"] = self.xmpp["xep_0402"].stanza.NS
917
+ iq["pubsub"]["publish"].append(item)
918
+
919
+ iq["pubsub"]["publish_options"] = _BOOKMARKS_OPTIONS
920
+
921
+ try:
922
+ await self.xmpp["xep_0356"].send_privileged_iq(iq)
923
+ except PermissionError:
924
+ warnings.warn(
925
+ "IQ privileges (XEP0356) are not set, we cannot add bookmarks for the user"
926
+ )
927
+ # fallback by forcing invitation
928
+ invite = True
929
+ except IqError as e:
930
+ warnings.warn(
931
+ f"Something went wrong while trying to set the bookmarks: {e}"
932
+ )
933
+ # fallback by forcing invitation
934
+ invite = True
935
+
936
+ if invite or (config.ALWAYS_INVITE_WHEN_ADDING_BOOKMARKS and not is_update):
937
+ self.session.send_gateway_invite(
938
+ self, reason="This group could not be added automatically for you"
939
+ )
940
+
941
+ async def on_avatar(
942
+ self, data: Optional[bytes], mime: Optional[str]
943
+ ) -> Optional[Union[int, str]]:
944
+ """
945
+ Called when the user tries to set the avatar of the room from an XMPP
946
+ client.
947
+
948
+ If the set avatar operation is completed, should return a legacy image
949
+ unique identifier. In this case the MUC avatar will be immediately
950
+ updated on the XMPP side.
951
+
952
+ If data is not None and this method returns None, then we assume that
953
+ self.set_avatar() will be called elsewhere, eg triggered by a legacy
954
+ room update event.
955
+
956
+ :param data: image data or None if the user meant to remove the avatar
957
+ :param mime: the mime type of the image. Since this is provided by
958
+ the XMPP client, there is no guarantee that this is valid or
959
+ correct.
960
+ :return: A unique avatar identifier, which will trigger
961
+ :py:meth:`slidge.group.room.LegacyMUC.set_avatar`. Alternatively, None, if
962
+ :py:meth:`.LegacyMUC.set_avatar` is meant to be awaited somewhere else.
963
+ """
964
+ raise NotImplementedError
965
+
966
+ admin_set_avatar = deprecated("LegacyMUC.on_avatar", on_avatar)
967
+
968
+ async def on_set_affiliation(
969
+ self,
970
+ contact: "LegacyContact",
971
+ affiliation: MucAffiliation,
972
+ reason: Optional[str],
973
+ nickname: Optional[str],
974
+ ):
975
+ """
976
+ Triggered when the user requests changing the affiliation of a contact
977
+ for this group,
978
+
979
+ Examples: promotion them to moderator, kick (affiliation=none),
980
+ ban (affiliation=outcast).
981
+
982
+ :param contact: The contact whose affiliation change is requested
983
+ :param affiliation: The new affiliation
984
+ :param reason: A reason for this affiliation change
985
+ :param nickname:
986
+ """
987
+ raise NotImplementedError
988
+
989
+ async def on_set_config(
990
+ self,
991
+ name: Optional[str],
992
+ description: Optional[str],
993
+ ):
994
+ """
995
+ Triggered when the user requests changing the room configuration.
996
+ Only title and description can be changed at the moment.
997
+
998
+ The legacy module is responsible for updating :attr:`.title` and/or
999
+ :attr:`.description` of this instance.
1000
+
1001
+ If :attr:`.HAS_DESCRIPTION` is set to False, description will always
1002
+ be ``None``.
1003
+
1004
+ :param name: The new name of the room.
1005
+ :param description: The new description of the room.
1006
+ """
1007
+ raise NotImplementedError
1008
+
1009
+ async def on_destroy_request(self, reason: Optional[str]):
1010
+ """
1011
+ Triggered when the user requests room destruction.
1012
+
1013
+ :param reason: Optionally, a reason for the destruction
1014
+ """
1015
+ raise NotImplementedError
1016
+
1017
+ async def parse_mentions(self, text: str) -> list[Mention]:
1018
+ await self.__fill_participants()
1019
+
1020
+ if len(self._participants_by_nicknames) == 0:
1021
+ return []
1022
+
1023
+ result = []
1024
+ for match in re.finditer(
1025
+ "|".join(
1026
+ sorted(
1027
+ [re.escape(nick) for nick in self._participants_by_nicknames],
1028
+ key=lambda nick: len(nick),
1029
+ reverse=True,
1030
+ )
1031
+ ),
1032
+ text,
1033
+ ):
1034
+ span = match.span()
1035
+ nick = match.group()
1036
+ if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION:
1037
+ continue
1038
+ if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION:
1039
+ participant = self._participants_by_nicknames[nick]
1040
+ if contact := participant.contact:
1041
+ result.append(Mention(contact=contact, start=span[0], end=span[1]))
1042
+ return result
1043
+
1044
+ async def on_set_subject(self, subject: str) -> None:
1045
+ """
1046
+ Triggered when the user requests changing the room subject.
1047
+
1048
+ The legacy module is responsible for updating :attr:`.subject` of this
1049
+ instance.
1050
+
1051
+ :param subject: The new subject for this room.
1052
+ """
1053
+ raise NotImplementedError
1054
+
1055
+
1056
+ def set_origin_id(msg: Message, origin_id: str):
1057
+ sub = ET.Element("{urn:xmpp:sid:0}origin-id")
1058
+ sub.attrib["id"] = origin_id
1059
+ msg.xml.append(sub)
1060
+
1061
+
1062
+ def int_or_none(x):
1063
+ try:
1064
+ return int(x)
1065
+ except ValueError:
1066
+ return None
1067
+
1068
+
1069
+ def equals_zero(x):
1070
+ if x is None:
1071
+ return False
1072
+ else:
1073
+ return x == 0
1074
+
1075
+
1076
+ def str_to_datetime_or_none(date: Optional[str]):
1077
+ if date is None:
1078
+ return
1079
+ try:
1080
+ return str_to_datetime(date)
1081
+ except ValueError:
1082
+ return None
1083
+
1084
+
1085
+ def bookmarks_form():
1086
+ form = Form()
1087
+ form["type"] = "submit"
1088
+ form.add_field(
1089
+ "FORM_TYPE",
1090
+ value="http://jabber.org/protocol/pubsub#publish-options",
1091
+ ftype="hidden",
1092
+ )
1093
+ form.add_field("pubsub#persist_items", value="1")
1094
+ form.add_field("pubsub#max_items", value="max")
1095
+ form.add_field("pubsub#send_last_published_item", value="never")
1096
+ form.add_field("pubsub#access_model", value="whitelist")
1097
+ return form
1098
+
1099
+
1100
+ _BOOKMARKS_OPTIONS = bookmarks_form()
1101
+ _WHITESPACE_OR_PUNCTUATION = string.whitespace + string.punctuation
1102
+
1103
+ log = logging.getLogger(__name__)