Coverage for slidge/group/room.py: 87%

674 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-11-07 05:11 +0000

1import json 

2import logging 

3import re 

4import string 

5import warnings 

6from copy import copy 

7from datetime import datetime, timedelta, timezone 

8from typing import TYPE_CHECKING, AsyncIterator, Generic, Optional, Self, Union 

9from uuid import uuid4 

10 

11from slixmpp import JID, Iq, Message, Presence 

12from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

13from slixmpp.jid import _unescape_node 

14from slixmpp.plugins.xep_0004 import Form 

15from slixmpp.plugins.xep_0060.stanza import Item 

16from slixmpp.plugins.xep_0082 import parse as str_to_datetime 

17from slixmpp.xmlstream import ET 

18 

19from ..contact.contact import LegacyContact 

20from ..contact.roster import ContactIsUser 

21from ..core import config 

22from ..core.mixins import StoredAttributeMixin 

23from ..core.mixins.avatar import AvatarMixin 

24from ..core.mixins.db import UpdateInfoMixin 

25from ..core.mixins.disco import ChatterDiscoMixin 

26from ..core.mixins.lock import NamedLockMixin 

27from ..core.mixins.recipient import ReactionRecipientMixin, ThreadRecipientMixin 

28from ..db.models import Room 

29from ..util import ABCSubclassableOnceAtMost 

30from ..util.types import ( 

31 HoleBound, 

32 LegacyGroupIdType, 

33 LegacyMessageType, 

34 LegacyParticipantType, 

35 LegacyUserIdType, 

36 Mention, 

37 MucAffiliation, 

38 MucType, 

39) 

40from ..util.util import deprecated, timeit, with_session 

41from .archive import MessageArchive 

42from .participant import LegacyParticipant 

43 

44if TYPE_CHECKING: 

45 from ..core.gateway import BaseGateway 

46 from ..core.session import BaseSession 

47 

48ADMIN_NS = "http://jabber.org/protocol/muc#admin" 

49 

50SubjectSetterType = Union[str, None, "LegacyContact", "LegacyParticipant"] 

51 

52 

53class LegacyMUC( 

54 Generic[ 

55 LegacyGroupIdType, LegacyMessageType, LegacyParticipantType, LegacyUserIdType 

56 ], 

57 UpdateInfoMixin, 

58 StoredAttributeMixin, 

59 AvatarMixin, 

60 NamedLockMixin, 

61 ChatterDiscoMixin, 

62 ReactionRecipientMixin, 

63 ThreadRecipientMixin, 

64 metaclass=ABCSubclassableOnceAtMost, 

65): 

66 """ 

67 A room, a.k.a. a Multi-User Chat. 

68 

69 MUC instances are obtained by calling :py:meth:`slidge.group.bookmarks.LegacyBookmarks` 

70 on the user's :py:class:`slidge.core.session.BaseSession`. 

71 """ 

72 

73 max_history_fetch = 100 

74 

75 type = MucType.CHANNEL 

76 is_group = True 

77 

78 DISCO_TYPE = "text" 

79 DISCO_CATEGORY = "conference" 

80 DISCO_NAME = "unnamed-room" 

81 

82 STABLE_ARCHIVE = False 

83 """ 

84 Because legacy events like reactions, editions, etc. don't all map to a stanza 

85 with a proper legacy ID, slidge usually cannot guarantee the stability of the archive 

86 across restarts. 

87 

88 Set this to True if you know what you're doing, but realistically, this can't 

89 be set to True until archive is permanently stored on disk by slidge. 

90 

91 This is just a flag on archive responses that most clients ignore anyway. 

92 """ 

93 

94 KEEP_BACKFILLED_PARTICIPANTS = False 

95 """ 

96 Set this to ``True`` if the participant list is not full after calling 

97 ``fill_participants()``. This is a workaround for networks with huge 

98 participant lists which do not map really well the MUCs where all presences 

99 are sent on join. 

100 It allows to ensure that the participants that last spoke (within the 

101 ``fill_history()`` method are effectively participants, thus making possible 

102 for XMPP clients to fetch their avatars. 

103 """ 

104 

105 _ALL_INFO_FILLED_ON_STARTUP = False 

106 """ 

107 Set this to true if the fill_participants() / fill_participants() design does not 

108 fit the legacy API, ie, no lazy loading of the participant list and history. 

109 """ 

110 

111 HAS_DESCRIPTION = True 

112 """ 

113 Set this to false if the legacy network does not allow setting a description 

114 for the group. In this case the description field will not be present in the 

115 room configuration form. 

116 """ 

117 

118 HAS_SUBJECT = True 

119 """ 

120 Set this to false if the legacy network does not allow setting a subject 

121 (sometimes also called topic) for the group. In this case, as a subject is 

122 recommended by :xep:`0045` ("SHALL"), the description (or the group name as 

123 ultimate fallback) will be used as the room subject. 

124 By setting this to false, an error will be returned when the :term:`User` 

125 tries to set the room subject. 

126 """ 

127 

128 _avatar_bare_jid = True 

129 archive: MessageArchive 

130 

131 def __init__(self, session: "BaseSession", legacy_id: LegacyGroupIdType, jid: JID): 

132 self.session = session 

133 self.xmpp: "BaseGateway" = session.xmpp 

134 

135 self.legacy_id = legacy_id 

136 self.jid = jid 

137 

138 self._user_resources = set[str]() 

139 

140 self.Participant = LegacyParticipant.get_self_or_unique_subclass() 

141 

142 self._subject = "" 

143 self._subject_setter: Optional[str] = None 

144 

145 self.pk: Optional[int] = None 

146 self._user_nick: Optional[str] = None 

147 

148 self._participants_filled = False 

149 self._history_filled = False 

150 self._description = "" 

151 self._subject_date: Optional[datetime] = None 

152 

153 self.__participants_store = self.xmpp.store.participants 

154 self.__store = self.xmpp.store.rooms 

155 

156 self._n_participants: Optional[int] = None 

157 

158 self.log = logging.getLogger(self.jid.bare) 

159 self._set_logger_name() 

160 super().__init__() 

161 

162 @property 

163 def n_participants(self): 

164 return self._n_participants 

165 

166 @n_participants.setter 

167 def n_participants(self, n_participants: Optional[int]): 

168 if self._n_participants == n_participants: 

169 return 

170 self._n_participants = n_participants 

171 if self._updating_info: 

172 return 

173 assert self.pk is not None 

174 self.__store.update_n_participants(self.pk, n_participants) 

175 

176 @property 

177 def user_jid(self): 

178 return self.session.user_jid 

179 

180 def _set_logger_name(self): 

181 self.log = logging.getLogger(f"{self.user_jid}:muc:{self}") 

182 

183 def __repr__(self): 

184 return f"<MUC #{self.pk} '{self.name}' ({self.legacy_id} - {self.jid.local})'>" 

185 

186 @property 

187 def subject_date(self) -> Optional[datetime]: 

188 return self._subject_date 

189 

190 @subject_date.setter 

191 def subject_date(self, when: Optional[datetime]) -> None: 

192 self._subject_date = when 

193 if self._updating_info: 

194 return 

195 assert self.pk is not None 

196 self.__store.update_subject_date(self.pk, when) 

197 

198 def __send_configuration_change(self, codes): 

199 part = self.get_system_participant() 

200 part.send_configuration_change(codes) 

201 

202 @property 

203 def user_nick(self): 

204 return self._user_nick or self.session.bookmarks.user_nick or self.user_jid.node 

205 

206 @user_nick.setter 

207 def user_nick(self, nick: str): 

208 self._user_nick = nick 

209 if not self._updating_info: 

210 self.__store.update_user_nick(self.pk, nick) 

211 

212 def add_user_resource(self, resource: str) -> None: 

213 self._user_resources.add(resource) 

214 assert self.pk is not None 

215 self.__store.set_resource(self.pk, self._user_resources) 

216 

217 def get_user_resources(self) -> set[str]: 

218 return self._user_resources 

219 

220 def remove_user_resource(self, resource: str) -> None: 

221 self._user_resources.remove(resource) 

222 assert self.pk is not None 

223 self.__store.set_resource(self.pk, self._user_resources) 

224 

225 async def __fill_participants(self): 

226 if self._participants_filled: 

227 return 

228 assert self.pk is not None 

229 async with self.lock("fill participants"): 

230 self._participants_filled = True 

231 async for p in self.fill_participants(): 

232 self.__participants_store.update(p) 

233 self.__store.set_participants_filled(self.pk) 

234 

235 async def get_participants(self) -> AsyncIterator[LegacyParticipant]: 

236 assert self.pk is not None 

237 if self._participants_filled: 

238 for db_participant in self.xmpp.store.participants.get_all( 

239 self.pk, user_included=True 

240 ): 

241 participant = self.Participant.from_store( 

242 self.session, db_participant, muc=self 

243 ) 

244 yield participant 

245 return 

246 

247 async with self.lock("fill participants"): 

248 self._participants_filled = True 

249 # We only fill the participants list if/when the MUC is first 

250 # joined by an XMPP client. But we may have instantiated 

251 resources = set[str]() 

252 for db_participant in self.xmpp.store.participants.get_all( 

253 self.pk, user_included=True 

254 ): 

255 participant = self.Participant.from_store( 

256 self.session, db_participant, muc=self 

257 ) 

258 resources.add(participant.jid.resource) 

259 yield participant 

260 async for p in self.fill_participants(): 

261 if p.jid.resource not in resources: 

262 yield p 

263 self.__store.set_participants_filled(self.pk) 

264 return 

265 

266 async def __fill_history(self): 

267 async with self.lock("fill history"): 

268 if self._history_filled: 

269 log.debug("History has already been fetched %s", self) 

270 return 

271 log.debug("Fetching history for %s", self) 

272 try: 

273 before, after = self.archive.get_hole_bounds() 

274 if before is not None: 

275 before = before._replace( 

276 id=self.xmpp.LEGACY_MSG_ID_TYPE(before.id) # type:ignore 

277 ) 

278 if after is not None: 

279 after = after._replace( 

280 id=self.xmpp.LEGACY_MSG_ID_TYPE(after.id) # type:ignore 

281 ) 

282 await self.backfill(before, after) 

283 except NotImplementedError: 

284 return 

285 except Exception as e: 

286 log.exception("Could not backfill: %s", e) 

287 assert self.pk is not None 

288 self.__store.set_history_filled(self.pk, True) 

289 self._history_filled = True 

290 

291 @property 

292 def name(self): 

293 return self.DISCO_NAME 

294 

295 @name.setter 

296 def name(self, n: str): 

297 if self.DISCO_NAME == n: 

298 return 

299 self.DISCO_NAME = n 

300 self._set_logger_name() 

301 self.__send_configuration_change((104,)) 

302 if self._updating_info: 

303 return 

304 assert self.pk is not None 

305 self.__store.update_name(self.pk, n) 

306 

307 @property 

308 def description(self): 

309 return self._description 

310 

311 @description.setter 

312 def description(self, d: str): 

313 if self._description == d: 

314 return 

315 self._description = d 

316 self.__send_configuration_change((104,)) 

317 if self._updating_info: 

318 return 

319 assert self.pk is not None 

320 self.__store.update_description(self.pk, d) 

321 

322 def on_presence_unavailable(self, p: Presence): 

323 pto = p.get_to() 

324 if pto.bare != self.jid.bare: 

325 return 

326 

327 pfrom = p.get_from() 

328 if pfrom.bare != self.user_jid.bare: 

329 return 

330 if (resource := pfrom.resource) in self._user_resources: 

331 if pto.resource != self.user_nick: 

332 self.log.debug( 

333 "Received 'leave group' request but with wrong nickname. %s", p 

334 ) 

335 self.remove_user_resource(resource) 

336 else: 

337 self.log.debug( 

338 "Received 'leave group' request but resource was not listed. %s", p 

339 ) 

340 

341 async def update_info(self): 

342 """ 

343 Fetch information about this group from the legacy network 

344 

345 This is awaited on MUC instantiation, and should be overridden to 

346 update the attributes of the group chat, like title, subject, number 

347 of participants etc. 

348 

349 To take advantage of the slidge avatar cache, you can check the .avatar 

350 property to retrieve the "legacy file ID" of the cached avatar. If there 

351 is no change, you should not call 

352 :py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar()` or 

353 attempt to modify 

354 the :attr:.avatar property. 

355 """ 

356 raise NotImplementedError 

357 

358 async def backfill( 

359 self, 

360 after: Optional[HoleBound] = None, 

361 before: Optional[HoleBound] = None, 

362 ): 

363 """ 

364 Override this if the legacy network provide server-side group archives. 

365 

366 In it, send history messages using ``self.get_participant(xxx).send_xxxx``, 

367 with the ``archive_only=True`` kwarg. This is only called once per slidge 

368 run for a given group. 

369 

370 :param after: Fetch messages after this one. If ``None``, it's up to you 

371 to decide how far you want to go in the archive. If it's not ``None``, 

372 it means slidge has some messages in this archive and you should really try 

373 to complete it to avoid "holes" in the history of this group. 

374 :param before: Fetch messages before this one. If ``None``, fetch all messages 

375 up to the most recent one 

376 """ 

377 raise NotImplementedError 

378 

379 async def fill_participants(self) -> AsyncIterator[LegacyParticipant]: 

380 """ 

381 This method should yield the list of all members of this group. 

382 

383 Typically, use ``participant = self.get_participant()``, self.get_participant_by_contact(), 

384 of self.get_user_participant(), and update their affiliation, hats, etc. 

385 before yielding them. 

386 """ 

387 return 

388 yield 

389 

390 @property 

391 def subject(self): 

392 return self._subject 

393 

394 @subject.setter 

395 def subject(self, s: str): 

396 if s == self._subject: 

397 return 

398 self.__get_subject_setter_participant().set_room_subject( 

399 s, None, self.subject_date, False 

400 ) 

401 

402 self._subject = s 

403 if self._updating_info: 

404 return 

405 assert self.pk is not None 

406 self.__store.update_subject(self.pk, s) 

407 

408 @property 

409 def is_anonymous(self): 

410 return self.type == MucType.CHANNEL 

411 

412 @property 

413 def subject_setter(self) -> Optional[str]: 

414 return self._subject_setter 

415 

416 @subject_setter.setter 

417 def subject_setter(self, subject_setter: SubjectSetterType) -> None: 

418 if isinstance(subject_setter, LegacyContact): 

419 subject_setter = subject_setter.name 

420 elif isinstance(subject_setter, LegacyParticipant): 

421 subject_setter = subject_setter.nickname 

422 

423 if subject_setter == self._subject_setter: 

424 return 

425 assert isinstance(subject_setter, str) 

426 self._subject_setter = subject_setter 

427 if self._updating_info: 

428 return 

429 assert self.pk is not None 

430 self.__store.update_subject_setter(self.pk, subject_setter) 

431 

432 def __get_subject_setter_participant(self) -> LegacyParticipant: 

433 if self._subject_setter is None: 

434 return self.get_system_participant() 

435 return self.Participant(self, self._subject_setter) 

436 

437 def features(self): 

438 features = [ 

439 "http://jabber.org/protocol/muc", 

440 "http://jabber.org/protocol/muc#stable_id", 

441 "http://jabber.org/protocol/muc#self-ping-optimization", 

442 "urn:xmpp:mam:2", 

443 "urn:xmpp:mam:2#extended", 

444 "urn:xmpp:sid:0", 

445 "muc_persistent", 

446 "vcard-temp", 

447 "urn:xmpp:ping", 

448 "urn:xmpp:occupant-id:0", 

449 "jabber:iq:register", 

450 self.xmpp.plugin["xep_0425"].stanza.NS, 

451 ] 

452 if self.type == MucType.GROUP: 

453 features.extend(["muc_membersonly", "muc_nonanonymous", "muc_hidden"]) 

454 elif self.type == MucType.CHANNEL: 

455 features.extend(["muc_open", "muc_semianonymous", "muc_public"]) 

456 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

457 features.extend(["muc_open", "muc_nonanonymous", "muc_public"]) 

458 return features 

459 

460 async def extended_features(self): 

461 is_group = self.type == MucType.GROUP 

462 

463 form = self.xmpp.plugin["xep_0004"].make_form(ftype="result") 

464 

465 form.add_field( 

466 "FORM_TYPE", "hidden", value="http://jabber.org/protocol/muc#roominfo" 

467 ) 

468 form.add_field("muc#roomconfig_persistentroom", "boolean", value=True) 

469 form.add_field("muc#roomconfig_changesubject", "boolean", value=False) 

470 form.add_field("muc#maxhistoryfetch", value=str(self.max_history_fetch)) 

471 form.add_field("muc#roominfo_subjectmod", "boolean", value=False) 

472 

473 if self._ALL_INFO_FILLED_ON_STARTUP or self._participants_filled: 

474 assert self.pk is not None 

475 n: Optional[int] = self.__participants_store.get_count(self.pk) 

476 else: 

477 n = self._n_participants 

478 if n is not None: 

479 form.add_field("muc#roominfo_occupants", value=str(n)) 

480 

481 if d := self.description: 

482 form.add_field("muc#roominfo_description", value=d) 

483 

484 if s := self.subject: 

485 form.add_field("muc#roominfo_subject", value=s) 

486 

487 if self._set_avatar_task: 

488 await self._set_avatar_task 

489 avatar = self.get_avatar() 

490 if avatar and (h := avatar.id): 

491 form.add_field( 

492 "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1", value=h 

493 ) 

494 form.add_field("muc#roominfo_avatarhash", "text-multi", value=[h]) 

495 

496 form.add_field("muc#roomconfig_membersonly", "boolean", value=is_group) 

497 form.add_field( 

498 "muc#roomconfig_whois", 

499 "list-single", 

500 value="moderators" if self.is_anonymous else "anyone", 

501 ) 

502 form.add_field("muc#roomconfig_publicroom", "boolean", value=not is_group) 

503 form.add_field("muc#roomconfig_allowpm", "boolean", value=False) 

504 

505 r = [form] 

506 

507 if reaction_form := await self.restricted_emoji_extended_feature(): 

508 r.append(reaction_form) 

509 

510 return r 

511 

512 def shutdown(self): 

513 user_jid = copy(self.jid) 

514 user_jid.resource = self.user_nick 

515 for user_full_jid in self.user_full_jids(): 

516 presence = self.xmpp.make_presence( 

517 pfrom=user_jid, pto=user_full_jid, ptype="unavailable" 

518 ) 

519 presence["muc"]["affiliation"] = "none" 

520 presence["muc"]["role"] = "none" 

521 presence["muc"]["status_codes"] = {110, 332} 

522 presence.send() 

523 

524 def user_full_jids(self): 

525 for r in self._user_resources: 

526 j = copy(self.user_jid) 

527 j.resource = r 

528 yield j 

529 

530 @property 

531 def user_muc_jid(self): 

532 user_muc_jid = copy(self.jid) 

533 user_muc_jid.resource = self.user_nick 

534 return user_muc_jid 

535 

536 def _legacy_to_xmpp(self, legacy_id: LegacyMessageType): 

537 return self.xmpp.store.sent.get_group_xmpp_id( 

538 self.session.user_pk, str(legacy_id) 

539 ) or self.session.legacy_to_xmpp_msg_id(legacy_id) 

540 

541 async def echo( 

542 self, msg: Message, legacy_msg_id: Optional[LegacyMessageType] = None 

543 ): 

544 origin_id = msg.get_origin_id() 

545 

546 msg.set_from(self.user_muc_jid) 

547 msg.set_id(msg.get_id()) 

548 if origin_id: 

549 # because of slixmpp internal magic, we need to do this to ensure the origin_id 

550 # is present 

551 set_origin_id(msg, origin_id) 

552 if legacy_msg_id: 

553 msg["stanza_id"]["id"] = self.session.legacy_to_xmpp_msg_id(legacy_msg_id) 

554 else: 

555 msg["stanza_id"]["id"] = str(uuid4()) 

556 msg["stanza_id"]["by"] = self.jid 

557 msg["occupant-id"]["id"] = "slidge-user" 

558 

559 self.archive.add(msg, await self.get_user_participant()) 

560 

561 for user_full_jid in self.user_full_jids(): 

562 self.log.debug("Echoing to %s", user_full_jid) 

563 msg = copy(msg) 

564 msg.set_to(user_full_jid) 

565 

566 msg.send() 

567 

568 def _get_cached_avatar_id(self): 

569 if self.pk is None: 

570 return None 

571 return self.xmpp.store.rooms.get_avatar_legacy_id(self.pk) 

572 

573 def _post_avatar_update(self) -> None: 

574 if self.pk is None: 

575 return 

576 assert self.pk is not None 

577 self.xmpp.store.rooms.set_avatar( 

578 self.pk, 

579 self._avatar_pk, 

580 None if self.avatar_id is None else str(self.avatar_id), 

581 ) 

582 self.__send_configuration_change((104,)) 

583 self._send_room_presence() 

584 

585 def _send_room_presence(self, user_full_jid: Optional[JID] = None): 

586 if user_full_jid is None: 

587 tos = self.user_full_jids() 

588 else: 

589 tos = [user_full_jid] 

590 for to in tos: 

591 p = self.xmpp.make_presence(pfrom=self.jid, pto=to) 

592 if (avatar := self.get_avatar()) and (h := avatar.id): 

593 p["vcard_temp_update"]["photo"] = h 

594 else: 

595 p["vcard_temp_update"]["photo"] = "" 

596 p.send() 

597 

598 @timeit 

599 @with_session 

600 async def join(self, join_presence: Presence): 

601 user_full_jid = join_presence.get_from() 

602 requested_nickname = join_presence.get_to().resource 

603 client_resource = user_full_jid.resource 

604 

605 if client_resource in self._user_resources: 

606 self.log.debug("Received join from a resource that is already joined.") 

607 

608 self.add_user_resource(client_resource) 

609 

610 if not requested_nickname or not client_resource: 

611 raise XMPPError("jid-malformed", by=self.jid) 

612 

613 self.log.debug( 

614 "Resource %s of %s wants to join room %s with nickname %s", 

615 client_resource, 

616 self.user_jid, 

617 self.legacy_id, 

618 requested_nickname, 

619 ) 

620 

621 user_nick = self.user_nick 

622 user_participant = None 

623 async for participant in self.get_participants(): 

624 if participant.is_user: 

625 user_participant = participant 

626 continue 

627 participant.send_initial_presence(full_jid=user_full_jid) 

628 

629 if user_participant is None: 

630 user_participant = await self.get_user_participant() 

631 if not user_participant.is_user: # type:ignore 

632 self.log.warning("is_user flag not set participant on user_participant") 

633 user_participant.is_user = True # type:ignore 

634 user_participant.send_initial_presence( 

635 user_full_jid, 

636 presence_id=join_presence["id"], 

637 nick_change=user_nick != requested_nickname, 

638 ) 

639 

640 history_params = join_presence["muc_join"]["history"] 

641 maxchars = int_or_none(history_params["maxchars"]) 

642 maxstanzas = int_or_none(history_params["maxstanzas"]) 

643 seconds = int_or_none(history_params["seconds"]) 

644 try: 

645 since = self.xmpp.plugin["xep_0082"].parse(history_params["since"]) 

646 except ValueError: 

647 since = None 

648 if seconds: 

649 since = datetime.now() - timedelta(seconds=seconds) 

650 if equals_zero(maxchars) or equals_zero(maxstanzas): 

651 log.debug("Joining client does not want any old-school MUC history-on-join") 

652 else: 

653 self.log.debug("Old school history fill") 

654 await self.__fill_history() 

655 await self.__old_school_history( 

656 user_full_jid, 

657 maxchars=maxchars, 

658 maxstanzas=maxstanzas, 

659 since=since, 

660 ) 

661 self.__get_subject_setter_participant().set_room_subject( 

662 self._subject if self.HAS_SUBJECT else (self.description or self.name), 

663 user_full_jid, 

664 self.subject_date, 

665 ) 

666 if t := self._set_avatar_task: 

667 await t 

668 self._send_room_presence(user_full_jid) 

669 

670 async def get_user_participant(self, **kwargs) -> "LegacyParticipantType": 

671 """ 

672 Get the participant representing the gateway user 

673 

674 :param kwargs: additional parameters for the :class:`.Participant` 

675 construction (optional) 

676 :return: 

677 """ 

678 p = await self.get_participant(self.user_nick, is_user=True, **kwargs) 

679 self.__store_participant(p) 

680 return p 

681 

682 def __store_participant(self, p: "LegacyParticipantType") -> None: 

683 # we don't want to update the participant list when we're filling history 

684 if not self.KEEP_BACKFILLED_PARTICIPANTS and self.get_lock("fill history"): 

685 return 

686 assert self.pk is not None 

687 p.pk = self.__participants_store.add(self.pk, p.nickname) 

688 self.__participants_store.update(p) 

689 

690 async def get_participant( 

691 self, 

692 nickname: str, 

693 raise_if_not_found=False, 

694 fill_first=False, 

695 store=True, 

696 **kwargs, 

697 ) -> "LegacyParticipantType": 

698 """ 

699 Get a participant by their nickname. 

700 

701 In non-anonymous groups, you probably want to use 

702 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

703 

704 :param nickname: Nickname of the participant (used as resource part in the MUC) 

705 :param raise_if_not_found: Raise XMPPError("item-not-found") if they are not 

706 in the participant list (internal use by slidge, plugins should not 

707 need that) 

708 :param fill_first: Ensure :meth:`.LegacyMUC.fill_participants()` has been called first 

709 (internal use by slidge, plugins should not need that) 

710 :param store: persistently store the user in the list of MUC participants 

711 :param kwargs: additional parameters for the :class:`.Participant` 

712 construction (optional) 

713 :return: 

714 """ 

715 if fill_first and not self._participants_filled: 

716 async for _ in self.get_participants(): 

717 pass 

718 if self.pk is not None: 

719 with self.xmpp.store.session(): 

720 stored = self.__participants_store.get_by_nickname( 

721 self.pk, nickname 

722 ) or self.__participants_store.get_by_resource(self.pk, nickname) 

723 if stored is not None: 

724 return self.Participant.from_store(self.session, stored) 

725 

726 if raise_if_not_found: 

727 raise XMPPError("item-not-found") 

728 p = self.Participant(self, nickname, **kwargs) 

729 if store and not self._updating_info: 

730 self.__store_participant(p) 

731 if ( 

732 not self.get_lock("fill participants") 

733 and not self.get_lock("fill history") 

734 and self._participants_filled 

735 and not p.is_user 

736 and not p.is_system 

737 ): 

738 p.send_affiliation_change() 

739 return p 

740 

741 def get_system_participant(self) -> "LegacyParticipantType": 

742 """ 

743 Get a pseudo-participant, representing the room itself 

744 

745 Can be useful for events that cannot be mapped to a participant, 

746 e.g. anonymous moderation events, or announces from the legacy 

747 service 

748 :return: 

749 """ 

750 return self.Participant(self, is_system=True) 

751 

752 async def get_participant_by_contact( 

753 self, c: "LegacyContact", **kwargs 

754 ) -> "LegacyParticipantType": 

755 """ 

756 Get a non-anonymous participant. 

757 

758 This is what should be used in non-anonymous groups ideally, to ensure 

759 that the Contact jid is associated to this participant 

760 

761 :param c: The :class:`.LegacyContact` instance corresponding to this contact 

762 :param kwargs: additional parameters for the :class:`.Participant` 

763 construction (optional) 

764 :return: 

765 """ 

766 await self.session.contacts.ready 

767 

768 if self.pk is not None: 

769 c._LegacyContact__ensure_pk() # type: ignore 

770 assert c.contact_pk is not None 

771 with self.__store.session(): 

772 stored = self.__participants_store.get_by_contact(self.pk, c.contact_pk) 

773 if stored is not None: 

774 return self.Participant.from_store( 

775 self.session, stored, muc=self, contact=c 

776 ) 

777 

778 nickname = c.name or _unescape_node(c.jid_username) 

779 

780 if self.pk is None: 

781 nick_available = True 

782 else: 

783 nick_available = self.__store.nickname_is_available(self.pk, nickname) 

784 

785 if not nick_available: 

786 self.log.debug("Nickname conflict") 

787 nickname = f"{nickname} ({c.jid_username})" 

788 p = self.Participant(self, nickname, **kwargs) 

789 p.contact = c 

790 

791 if self._updating_info: 

792 return p 

793 

794 self.__store_participant(p) 

795 # FIXME: this is not great but given the current design, 

796 # during participants fill and history backfill we do not 

797 # want to send presence, because we might :update affiliation 

798 # and role afterwards. 

799 # We need a refactor of the MUC class… later™ 

800 if ( 

801 self._participants_filled 

802 and not self.get_lock("fill participants") 

803 and not self.get_lock("fill history") 

804 ): 

805 p.send_last_presence(force=True, no_cache_online=True) 

806 return p 

807 

808 async def get_participant_by_legacy_id( 

809 self, legacy_id: LegacyUserIdType, **kwargs 

810 ) -> "LegacyParticipantType": 

811 try: 

812 c = await self.session.contacts.by_legacy_id(legacy_id) 

813 except ContactIsUser: 

814 return await self.get_user_participant(**kwargs) 

815 return await self.get_participant_by_contact(c, **kwargs) 

816 

817 def remove_participant( 

818 self, 

819 p: "LegacyParticipantType", 

820 kick=False, 

821 ban=False, 

822 reason: str | None = None, 

823 ): 

824 """ 

825 Call this when a participant leaves the room 

826 

827 :param p: The participant 

828 :param kick: Whether the participant left because they were kicked 

829 :param ban: Whether the participant left because they were banned 

830 :param reason: Optionally, a reason why the participant was removed. 

831 """ 

832 if kick and ban: 

833 raise TypeError("Either kick or ban") 

834 self.__participants_store.delete(p.pk) 

835 if kick: 

836 codes = {307} 

837 elif ban: 

838 codes = {301} 

839 else: 

840 codes = None 

841 presence = p._make_presence(ptype="unavailable", status_codes=codes) 

842 p._affiliation = "outcast" if ban else "none" 

843 p._role = "none" 

844 if reason: 

845 presence["muc"].set_item_attr("reason", reason) 

846 p._send(presence) 

847 

848 def rename_participant(self, old_nickname: str, new_nickname: str): 

849 assert self.pk is not None 

850 with self.xmpp.store.session(): 

851 stored = self.__participants_store.get_by_nickname(self.pk, old_nickname) 

852 if stored is None: 

853 self.log.debug("Tried to rename a participant that we didn't know") 

854 return 

855 p = self.Participant.from_store(self.session, stored) 

856 if p.nickname == old_nickname: 

857 p.nickname = new_nickname 

858 

859 async def __old_school_history( 

860 self, 

861 full_jid: JID, 

862 maxchars: Optional[int] = None, 

863 maxstanzas: Optional[int] = None, 

864 seconds: Optional[int] = None, 

865 since: Optional[datetime] = None, 

866 ): 

867 """ 

868 Old-style history join (internal slidge use) 

869 

870 :param full_jid: 

871 :param maxchars: 

872 :param maxstanzas: 

873 :param seconds: 

874 :param since: 

875 :return: 

876 """ 

877 if since is None: 

878 if seconds is None: 

879 start_date = datetime.now(tz=timezone.utc) - timedelta(days=1) 

880 else: 

881 start_date = datetime.now(tz=timezone.utc) - timedelta(seconds=seconds) 

882 else: 

883 start_date = since or datetime.now(tz=timezone.utc) - timedelta(days=1) 

884 

885 for h_msg in self.archive.get_all( 

886 start_date=start_date, end_date=None, last_page_n=maxstanzas 

887 ): 

888 msg = h_msg.stanza_component_ns 

889 msg["delay"]["stamp"] = h_msg.when 

890 msg.set_to(full_jid) 

891 self.xmpp.send(msg, False) 

892 

893 async def send_mam(self, iq: Iq): 

894 await self.__fill_history() 

895 

896 form_values = iq["mam"]["form"].get_values() 

897 

898 start_date = str_to_datetime_or_none(form_values.get("start")) 

899 end_date = str_to_datetime_or_none(form_values.get("end")) 

900 

901 after_id = form_values.get("after-id") 

902 before_id = form_values.get("before-id") 

903 

904 sender = form_values.get("with") 

905 

906 ids = form_values.get("ids") or () 

907 

908 if max_str := iq["mam"]["rsm"]["max"]: 

909 try: 

910 max_results = int(max_str) 

911 except ValueError: 

912 max_results = None 

913 else: 

914 max_results = None 

915 

916 after_id_rsm = iq["mam"]["rsm"]["after"] 

917 after_id = after_id_rsm or after_id 

918 

919 before_rsm = iq["mam"]["rsm"]["before"] 

920 if before_rsm is True and max_results is not None: 

921 last_page_n = max_results 

922 else: 

923 last_page_n = None 

924 

925 first = None 

926 last = None 

927 count = 0 

928 

929 it = self.archive.get_all( 

930 start_date, 

931 end_date, 

932 before_id, 

933 after_id, 

934 ids, 

935 last_page_n, 

936 sender, 

937 bool(iq["mam"]["flip_page"]), 

938 ) 

939 

940 for history_msg in it: 

941 last = xmpp_id = history_msg.id 

942 if first is None: 

943 first = xmpp_id 

944 

945 wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from()) 

946 wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"] 

947 wrapper_msg["mam_result"]["id"] = xmpp_id 

948 wrapper_msg["mam_result"].append(history_msg.forwarded()) 

949 

950 wrapper_msg.send() 

951 count += 1 

952 

953 if max_results and count == max_results: 

954 break 

955 

956 if max_results: 

957 try: 

958 next(it) 

959 except StopIteration: 

960 complete = True 

961 else: 

962 complete = False 

963 else: 

964 complete = True 

965 

966 reply = iq.reply() 

967 if not self.STABLE_ARCHIVE: 

968 reply["mam_fin"]["stable"] = "false" 

969 if complete: 

970 reply["mam_fin"]["complete"] = "true" 

971 reply["mam_fin"]["rsm"]["first"] = first 

972 reply["mam_fin"]["rsm"]["last"] = last 

973 reply["mam_fin"]["rsm"]["count"] = str(count) 

974 reply.send() 

975 

976 async def send_mam_metadata(self, iq: Iq): 

977 await self.__fill_history() 

978 await self.archive.send_metadata(iq) 

979 

980 async def kick_resource(self, r: str): 

981 """ 

982 Kick a XMPP client of the user. (slidge internal use) 

983 

984 :param r: The resource to kick 

985 """ 

986 pto = self.user_jid 

987 pto.resource = r 

988 p = self.xmpp.make_presence( 

989 pfrom=(await self.get_user_participant()).jid, pto=pto 

990 ) 

991 p["type"] = "unavailable" 

992 p["muc"]["affiliation"] = "none" 

993 p["muc"]["role"] = "none" 

994 p["muc"]["status_codes"] = {110, 333} 

995 p.send() 

996 

997 async def add_to_bookmarks(self, auto_join=True, invite=False, preserve=True): 

998 """ 

999 Add the MUC to the user's XMPP bookmarks (:xep:`0402') 

1000 

1001 This requires that slidge has the IQ privileged set correctly 

1002 on the XMPP server 

1003 

1004 :param auto_join: whether XMPP clients should automatically join 

1005 this MUC on startup. In theory, XMPP clients will receive 

1006 a "push" notification when this is called, and they will 

1007 join if they are online. 

1008 :param invite: send an invitation to join this MUC emanating from 

1009 the gateway. While this should not be strictly necessary, 

1010 it can help for clients that do not support :xep:`0402`, or 

1011 that have 'do not honor bookmarks auto-join' turned on in their 

1012 settings. 

1013 :param preserve: preserve auto-join and bookmarks extensions 

1014 set by the user outside slidge 

1015 """ 

1016 item = Item() 

1017 item["id"] = self.jid 

1018 

1019 iq = Iq(stype="get", sfrom=self.user_jid, sto=self.user_jid) 

1020 iq["pubsub"]["items"]["node"] = self.xmpp["xep_0402"].stanza.NS 

1021 iq["pubsub"]["items"].append(item) 

1022 

1023 is_update = False 

1024 if preserve: 

1025 try: 

1026 ans = await self.xmpp["xep_0356"].send_privileged_iq(iq) 

1027 is_update = len(ans["pubsub"]["items"]) == 1 

1028 # this below creates the item if it wasn't here already 

1029 # (slixmpp annoying magic) 

1030 item = ans["pubsub"]["items"]["item"] 

1031 item["id"] = self.jid 

1032 except (IqError, IqTimeout): 

1033 item["conference"]["autojoin"] = auto_join 

1034 except PermissionError: 

1035 warnings.warn( 

1036 "IQ privileges (XEP0356) are not set, we cannot fetch the user bookmarks" 

1037 ) 

1038 else: 

1039 # if the bookmark is already present, we preserve it as much as 

1040 # possible, especially custom <extensions> 

1041 self.log.debug("Existing: %s", item) 

1042 # if it's an update, we do not touch the auto join flag 

1043 if not is_update: 

1044 item["conference"]["autojoin"] = auto_join 

1045 else: 

1046 item["conference"]["autojoin"] = auto_join 

1047 

1048 item["conference"]["nick"] = self.user_nick 

1049 iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid) 

1050 iq["pubsub"]["publish"]["node"] = self.xmpp["xep_0402"].stanza.NS 

1051 iq["pubsub"]["publish"].append(item) 

1052 

1053 iq["pubsub"]["publish_options"] = _BOOKMARKS_OPTIONS 

1054 

1055 try: 

1056 await self.xmpp["xep_0356"].send_privileged_iq(iq) 

1057 except PermissionError: 

1058 warnings.warn( 

1059 "IQ privileges (XEP0356) are not set, we cannot add bookmarks for the user" 

1060 ) 

1061 # fallback by forcing invitation 

1062 invite = True 

1063 except IqError as e: 

1064 warnings.warn( 

1065 f"Something went wrong while trying to set the bookmarks: {e}" 

1066 ) 

1067 # fallback by forcing invitation 

1068 invite = True 

1069 

1070 if invite or (config.ALWAYS_INVITE_WHEN_ADDING_BOOKMARKS and not is_update): 

1071 self.session.send_gateway_invite( 

1072 self, reason="This group could not be added automatically for you" 

1073 ) 

1074 

1075 async def on_avatar( 

1076 self, data: Optional[bytes], mime: Optional[str] 

1077 ) -> Optional[Union[int, str]]: 

1078 """ 

1079 Called when the user tries to set the avatar of the room from an XMPP 

1080 client. 

1081 

1082 If the set avatar operation is completed, should return a legacy image 

1083 unique identifier. In this case the MUC avatar will be immediately 

1084 updated on the XMPP side. 

1085 

1086 If data is not None and this method returns None, then we assume that 

1087 self.set_avatar() will be called elsewhere, eg triggered by a legacy 

1088 room update event. 

1089 

1090 :param data: image data or None if the user meant to remove the avatar 

1091 :param mime: the mime type of the image. Since this is provided by 

1092 the XMPP client, there is no guarantee that this is valid or 

1093 correct. 

1094 :return: A unique avatar identifier, which will trigger 

1095 :py:meth:`slidge.group.room.LegacyMUC.set_avatar`. Alternatively, None, if 

1096 :py:meth:`.LegacyMUC.set_avatar` is meant to be awaited somewhere else. 

1097 """ 

1098 raise NotImplementedError 

1099 

1100 admin_set_avatar = deprecated("LegacyMUC.on_avatar", on_avatar) 

1101 

1102 async def on_set_affiliation( 

1103 self, 

1104 contact: "LegacyContact", 

1105 affiliation: MucAffiliation, 

1106 reason: Optional[str], 

1107 nickname: Optional[str], 

1108 ): 

1109 """ 

1110 Triggered when the user requests changing the affiliation of a contact 

1111 for this group. 

1112 

1113 Examples: promotion them to moderator, ban (affiliation=outcast). 

1114 

1115 :param contact: The contact whose affiliation change is requested 

1116 :param affiliation: The new affiliation 

1117 :param reason: A reason for this affiliation change 

1118 :param nickname: 

1119 """ 

1120 raise NotImplementedError 

1121 

1122 async def on_kick(self, contact: "LegacyContact", reason: Optional[str]): 

1123 """ 

1124 Triggered when the user requests changing the role of a contact 

1125 to "none" for this group. Action commonly known as "kick". 

1126 

1127 :param contact: Contact to be kicked 

1128 :param reason: A reason for this kick 

1129 """ 

1130 raise NotImplementedError 

1131 

1132 async def on_set_config( 

1133 self, 

1134 name: Optional[str], 

1135 description: Optional[str], 

1136 ): 

1137 """ 

1138 Triggered when the user requests changing the room configuration. 

1139 Only title and description can be changed at the moment. 

1140 

1141 The legacy module is responsible for updating :attr:`.title` and/or 

1142 :attr:`.description` of this instance. 

1143 

1144 If :attr:`.HAS_DESCRIPTION` is set to False, description will always 

1145 be ``None``. 

1146 

1147 :param name: The new name of the room. 

1148 :param description: The new description of the room. 

1149 """ 

1150 raise NotImplementedError 

1151 

1152 async def on_destroy_request(self, reason: Optional[str]): 

1153 """ 

1154 Triggered when the user requests room destruction. 

1155 

1156 :param reason: Optionally, a reason for the destruction 

1157 """ 

1158 raise NotImplementedError 

1159 

1160 async def parse_mentions(self, text: str) -> list[Mention]: 

1161 with self.__store.session(): 

1162 await self.__fill_participants() 

1163 assert self.pk is not None 

1164 participants = { 

1165 p.nickname: p for p in self.__participants_store.get_all(self.pk) 

1166 } 

1167 

1168 if len(participants) == 0: 

1169 return [] 

1170 

1171 result = [] 

1172 for match in re.finditer( 

1173 "|".join( 

1174 sorted( 

1175 [re.escape(nick) for nick in participants.keys()], 

1176 key=lambda nick: len(nick), 

1177 reverse=True, 

1178 ) 

1179 ), 

1180 text, 

1181 ): 

1182 span = match.span() 

1183 nick = match.group() 

1184 if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION: 

1185 continue 

1186 if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION: 

1187 participant = self.Participant.from_store( 

1188 self.session, participants[nick] 

1189 ) 

1190 if contact := participant.contact: 

1191 result.append( 

1192 Mention(contact=contact, start=span[0], end=span[1]) 

1193 ) 

1194 return result 

1195 

1196 async def on_set_subject(self, subject: str) -> None: 

1197 """ 

1198 Triggered when the user requests changing the room subject. 

1199 

1200 The legacy module is responsible for updating :attr:`.subject` of this 

1201 instance. 

1202 

1203 :param subject: The new subject for this room. 

1204 """ 

1205 raise NotImplementedError 

1206 

1207 @classmethod 

1208 def from_store(cls, session, stored: Room, *args, **kwargs) -> Self: 

1209 muc = cls( 

1210 session, 

1211 cls.xmpp.LEGACY_ROOM_ID_TYPE(stored.legacy_id), 

1212 stored.jid, 

1213 *args, # type: ignore 

1214 **kwargs, # type: ignore 

1215 ) 

1216 muc.pk = stored.id 

1217 muc.type = stored.muc_type # type: ignore 

1218 muc._user_nick = stored.user_nick 

1219 if stored.name: 

1220 muc.DISCO_NAME = stored.name 

1221 if stored.description: 

1222 muc._description = stored.description 

1223 if (data := stored.extra_attributes) is not None: 

1224 muc.deserialize_extra_attributes(data) 

1225 muc._subject = stored.subject or "" 

1226 if stored.subject_date is not None: 

1227 muc._subject_date = stored.subject_date.replace(tzinfo=timezone.utc) 

1228 muc._participants_filled = stored.participants_filled 

1229 muc._n_participants = stored.n_participants 

1230 muc._history_filled = stored.history_filled 

1231 if stored.user_resources is not None: 

1232 muc._user_resources = set(json.loads(stored.user_resources)) 

1233 muc._subject_setter = stored.subject_setter 

1234 muc.archive = MessageArchive(muc.pk, session.xmpp.store.mam) 

1235 muc._set_logger_name() 

1236 muc._AvatarMixin__avatar_unique_id = ( # type:ignore 

1237 None 

1238 if stored.avatar_legacy_id is None 

1239 else session.xmpp.AVATAR_ID_TYPE(stored.avatar_legacy_id) 

1240 ) 

1241 muc._avatar_pk = stored.avatar_id 

1242 return muc 

1243 

1244 

1245def set_origin_id(msg: Message, origin_id: str): 

1246 sub = ET.Element("{urn:xmpp:sid:0}origin-id") 

1247 sub.attrib["id"] = origin_id 

1248 msg.xml.append(sub) 

1249 

1250 

1251def int_or_none(x): 

1252 try: 

1253 return int(x) 

1254 except ValueError: 

1255 return None 

1256 

1257 

1258def equals_zero(x): 

1259 if x is None: 

1260 return False 

1261 else: 

1262 return x == 0 

1263 

1264 

1265def str_to_datetime_or_none(date: Optional[str]): 

1266 if date is None: 

1267 return 

1268 try: 

1269 return str_to_datetime(date) 

1270 except ValueError: 

1271 return None 

1272 

1273 

1274def bookmarks_form(): 

1275 form = Form() 

1276 form["type"] = "submit" 

1277 form.add_field( 

1278 "FORM_TYPE", 

1279 value="http://jabber.org/protocol/pubsub#publish-options", 

1280 ftype="hidden", 

1281 ) 

1282 form.add_field("pubsub#persist_items", value="1") 

1283 form.add_field("pubsub#max_items", value="max") 

1284 form.add_field("pubsub#send_last_published_item", value="never") 

1285 form.add_field("pubsub#access_model", value="whitelist") 

1286 return form 

1287 

1288 

1289_BOOKMARKS_OPTIONS = bookmarks_form() 

1290_WHITESPACE_OR_PUNCTUATION = string.whitespace + string.punctuation 

1291 

1292log = logging.getLogger(__name__)