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

971 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-29 05:05 +0000

1import hashlib 

2import json 

3import logging 

4import re 

5import string 

6import uuid 

7import warnings 

8from asyncio import Lock 

9from collections.abc import AsyncIterator, Iterable, Iterator 

10from contextlib import asynccontextmanager 

11from copy import copy 

12from datetime import UTC, datetime, timedelta 

13from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload 

14 

15import sqlalchemy as sa 

16from slixmpp import JID, Iq, Message, Presence 

17from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

18from slixmpp.plugins.xep_0004.stanza.form import Form 

19from slixmpp.plugins.xep_0045.stanza import MUCUserItem 

20from slixmpp.plugins.xep_0060.stanza import Item 

21from slixmpp.plugins.xep_0082 import parse as str_to_datetime 

22from slixmpp.plugins.xep_0469.stanza import NS as PINNING_NS 

23from slixmpp.plugins.xep_0492.stanza import NS as NOTIFY_NS 

24from slixmpp.plugins.xep_0492.stanza import WhenLiteral 

25from slixmpp.xmlstream import ET 

26from sqlalchemy.exc import IntegrityError 

27from sqlalchemy.orm import Session as OrmSession 

28from sqlalchemy.orm import object_session 

29from sqlalchemy.orm.exc import DetachedInstanceError 

30 

31from ..contact.contact import LegacyContact 

32from ..contact.roster import ContactIsUser 

33from ..core.mixins.avatar import AvatarMixin 

34from ..core.mixins.base import SessionBound 

35from ..core.mixins.disco import ChatterDiscoMixin 

36from ..core.mixins.recipient import RecipientMixin 

37from ..db.models import ArchivedMessage, Participant, Room, Space 

38from ..util.jid_escaping import unescape_node 

39from ..util.types import ( 

40 AnyParticipant, 

41 AnySession, 

42 HoleBound, 

43 LegacyParticipantType, 

44 Mention, 

45 MucAffiliation, 

46 MUCMessage, 

47 MUCSticker, 

48 MucType, 

49) 

50from ..util.util import derive_wired_class, timeit 

51from .archive import MessageArchive 

52from .participant import LegacyParticipant, escape_nickname 

53 

54if TYPE_CHECKING: 

55 from ..command.base import MUCCommand 

56 from ..db.avatar import CachedAvatar 

57 

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

59 

60type SubjectSetterType = "str | LegacyContact | AnyParticipant | None" 

61 

62 

63class LegacyMUC[LegacyParticipantType: AnyParticipant]( 

64 AvatarMixin, 

65 ChatterDiscoMixin, 

66 RecipientMixin, 

67 SessionBound, 

68): 

69 """ 

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

71 

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

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

74 """ 

75 

76 max_history_fetch = 100 

77 

78 is_group: Literal[True] = True 

79 

80 DISCO_TYPE = "text" 

81 DISCO_CATEGORY = "conference" 

82 

83 STABLE_ARCHIVE = False 

84 """ 

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

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

87 across restarts. 

88 

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

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

91 

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

93 """ 

94 

95 """ 

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

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

98 """ 

99 

100 HAS_DESCRIPTION = True 

101 """ 

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

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

104 room configuration form. 

105 """ 

106 

107 HAS_SUBJECT = True 

108 """ 

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

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

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

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

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

114 tries to set the room subject. 

115 """ 

116 

117 archive: MessageArchive 

118 

119 stored: Room 

120 

121 commands: ClassVar[dict[str, "type[MUCCommand[Any]]"]] = {} 

122 commands_chat: ClassVar[dict[str, "type[MUCCommand[Any]]"]] = {} 

123 

124 participant_cls: type[LegacyParticipantType] 

125 """ 

126 The concrete :class:`.LegacyParticipant` subclass this MUC produces. 

127 

128 Derived automatically from the generic parameter, e.g., 

129 ``class MUC(LegacyMUC[Participant])`` produces ``Participant`` instances. 

130 """ 

131 

132 is_participant: Literal[False] = False 

133 

134 def __init_subclass__(cls, **kwargs: object) -> None: 

135 super().__init_subclass__(**kwargs) 

136 derive_wired_class(cls, LegacyMUC, "participant_cls") 

137 

138 def __init__(self, session: AnySession, stored: Room) -> None: 

139 self.session = session 

140 self.xmpp = session.xmpp 

141 self.stored = stored 

142 self._set_logger() 

143 super().__init__() 

144 

145 self.archive = MessageArchive(stored, self.xmpp.store) 

146 

147 async def on_message( 

148 self, message: MUCMessage[LegacyParticipantType] 

149 ) -> str | None: 

150 """ 

151 Triggered when the user sends a message to this :term:`MUC`. 

152 

153 :return: A message ID of that can be used later to further reference 

154 this message (reactions, read marks, etc.). 

155 """ 

156 raise NotImplementedError 

157 

158 async def on_sticker(self, sticker: MUCSticker) -> str | None: 

159 """ 

160 Triggered when the user sends a sticker to this :term:`MUC`. 

161 

162 :param sticker: The sticker sent by the user. 

163 

164 :return: A message ID of that can be used later to further reference 

165 this message (reactions, read marks, etc.). 

166 """ 

167 raise NotImplementedError 

168 

169 def pop_unread_xmpp_ids_up_to(self, horizon_xmpp_id: str) -> list[str]: 

170 """ 

171 Return XMPP msg ids sent in this group up to a given XMPP msg id. 

172 

173 Plugins have no reason to use this, but it is used by slidge core 

174 for legacy networks that need to mark *all* messages as read (most XMPP 

175 clients only send a read marker for the latest message). 

176 

177 This has side effects: all messages up to the horizon XMPP id will be marked 

178 as read in the DB. If the horizon XMPP id is not found, all messages of this 

179 MUC will be marked as read. 

180 

181 :param horizon_xmpp_id: The latest message 

182 :return: A list of XMPP ids if horizon_xmpp_id was not found 

183 """ 

184 with self.xmpp.store.session() as orm: 

185 assert self.stored.id is not None 

186 ids = self.xmpp.store.mam.pop_unread_up_to( 

187 orm, self.stored.id, horizon_xmpp_id 

188 ) 

189 orm.commit() 

190 return ids 

191 

192 def participant_from_store( 

193 self, 

194 stored: Participant, 

195 contact: LegacyContact | None = None, 

196 for_join: bool = False, 

197 ) -> LegacyParticipantType: 

198 if contact is None and stored.contact_id is not None and not for_join: 

199 if "contact" in sa.inspect(stored).unloaded: 

200 with self.xmpp.store.session() as orm, orm.no_autoflush: 

201 stored = orm.merge(stored) 

202 orm.refresh(stored, attribute_names=["contact"]) 

203 assert stored.contact is not None 

204 contact = self.session.contacts.from_store(stored.contact) 

205 return self.participant_cls( 

206 self, stored=stored, contact=contact, for_join=for_join 

207 ) 

208 

209 @property 

210 def jid(self) -> JID: 

211 return self.stored.jid 

212 

213 @jid.setter 

214 def jid(self, x: JID) -> None: 

215 # FIXME: without this, mypy yields 

216 # "Cannot override writeable attribute with read-only property" 

217 # But it does not happen for LegacyContact. WTF? 

218 raise RuntimeError 

219 

220 @property 

221 def legacy_id(self) -> str: 

222 return self.stored.legacy_id 

223 

224 def __stored_space(self) -> Space | None: 

225 if self.stored.space_id is None: 

226 return None 

227 if ( 

228 "space" in sa.inspect(self.stored).unloaded 

229 and object_session(self.stored) is None 

230 ): 

231 with self.xmpp.store.session() as orm, orm.no_autoflush: 

232 orm.add(self.stored) 

233 return self.stored.space 

234 return self.stored.space 

235 

236 @property 

237 def space_legacy_id(self) -> str | None: 

238 if self.stored.space_id is None: 

239 return None 

240 stored = self.__stored_space() 

241 if stored is None: 

242 return None 

243 return stored.legacy_id 

244 

245 @space_legacy_id.setter 

246 def space_legacy_id(self, legacy_id: str) -> None: 

247 if self.space_legacy_id == legacy_id: 

248 return 

249 with self.orm(expire_on_commit=False) as orm: 

250 space = self.xmpp.store.spaces.add_or_get(orm, self.user_pk, str(legacy_id)) 

251 if self._updating_info: 

252 # Merge *before* assigning the space. ``Room.space`` has a 

253 # backref to ``Space.rooms``: assigning ``space`` to the 

254 # (detached) ``self.stored`` first would append it to 

255 # ``space.rooms``, and then ``merge()`` would load a second 

256 # instance of the same row in this session and append that one 

257 # too. ``space.rooms`` would then hold two distinct objects for 

258 # the same primary key, and any later ``orm.add(self.stored)`` 

259 # in another session would cascade through it and raise 

260 # "Can't attach instance <Room>; another instance with key 

261 # [...] is already present in this session." 

262 with orm.no_autoflush: 

263 stored = orm.merge(self.stored) 

264 stored.space = space 

265 self.stored = stored 

266 return 

267 self.stored.space = space 

268 orm.add(self.stored) 

269 orm.commit() 

270 

271 def orm( 

272 self, 

273 **kwargs: Any, # noqa:ANN401 

274 ) -> OrmSession: 

275 return self.xmpp.store.session(**kwargs) 

276 

277 @property 

278 def type(self) -> MucType: 

279 return self.stored.muc_type 

280 

281 @type.setter 

282 def type(self, type_: MucType) -> None: 

283 if self.type == type_: 

284 return 

285 self.update_stored_attribute(muc_type=type_) 

286 

287 @property 

288 def n_participants(self) -> int | None: 

289 self.stored.ensure_loaded("disco", self.xmpp.store.session) 

290 return self.stored.n_participants 

291 

292 @n_participants.setter 

293 def n_participants(self, n_participants: int | None) -> None: 

294 if self.n_participants == n_participants: 

295 return 

296 self.update_stored_attribute(n_participants=n_participants) 

297 

298 def _set_logger(self) -> None: 

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

300 

301 def __repr__(self) -> str: 

302 return f"<MUC #{self.stored.id} '{self.name}' ({self.stored.legacy_id} - {self.jid.user})'>" 

303 

304 @property 

305 def subject_date(self) -> datetime | None: 

306 self.stored.ensure_loaded("subject", self.xmpp.store.session) 

307 if self.stored.subject_date is None: 

308 return None 

309 return self.stored.subject_date.replace(tzinfo=UTC) 

310 

311 @subject_date.setter 

312 def subject_date(self, when: datetime | None) -> None: 

313 if self.subject_date == when: 

314 return 

315 self.update_stored_attribute(subject_date=when) 

316 

317 def __send_configuration_change(self, codes: tuple[int, ...]) -> None: 

318 part = self.get_system_participant() 

319 part.send_configuration_change(codes) 

320 

321 @property 

322 def user_nick(self) -> str: 

323 return ( 

324 self.stored.user_nick 

325 or self.session.bookmarks.user_nick 

326 or self.user_jid.node 

327 ) 

328 

329 @user_nick.setter 

330 def user_nick(self, nick: str) -> None: 

331 if nick == self.user_nick: 

332 return 

333 self.update_stored_attribute(user_nick=nick) 

334 

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

336 stored_set = self.get_user_resources() 

337 if resource in stored_set: 

338 return 

339 stored_set.add(resource) 

340 self.update_stored_attribute( 

341 user_resources=(json.dumps(list(stored_set)) if stored_set else None) 

342 ) 

343 

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

345 stored_str = self.stored.user_resources 

346 if stored_str is None: 

347 return set() 

348 return set(json.loads(stored_str)) 

349 

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

351 stored_set = self.get_user_resources() 

352 if resource not in stored_set: 

353 return 

354 stored_set.remove(resource) 

355 self.update_stored_attribute( 

356 user_resources=(json.dumps(list(stored_set)) if stored_set else None) 

357 ) 

358 

359 @asynccontextmanager 

360 async def lock(self, id_: str) -> AsyncIterator[None]: 

361 async with self.session.lock((self.legacy_id, id_)): 

362 yield 

363 

364 def get_lock(self, id_: str) -> Lock | None: 

365 return self.session.get_lock((self.legacy_id, id_)) 

366 

367 async def __fill_participants(self) -> None: 

368 if self.participants_filled: 

369 return 

370 

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

372 with self.xmpp.store.session(expire_on_commit=False) as orm: 

373 orm.add(self.stored) 

374 with orm.no_autoflush: 

375 orm.refresh(self.stored, ["participants_filled"]) 

376 if self.participants_filled: 

377 return 

378 self.log.debug("Filling participants") 

379 parts: list[Participant] = [] 

380 resources = set[str]() 

381 # During fill_participants(), self.get_participant*() methods may 

382 # return a participant with a conflicting nick/resource. 

383 user_found = False 

384 async for participant in self.fill_participants(): 

385 if participant.is_user: 

386 user_found = True 

387 if participant.stored.resource in resources: 

388 self.log.debug( 

389 "Participant '%s' was yielded more than once by fill_participants(), ignoring", 

390 participant.stored.resource, 

391 ) 

392 continue 

393 parts.append(participant.stored) 

394 resources.add(participant.stored.resource) 

395 

396 if not user_found: 

397 participant = await self.get_user_participant() 

398 if participant.stored.resource in resources: 

399 for p in parts: 

400 if p.resource == participant.jid.resource: 

401 p.is_user = True 

402 else: 

403 parts.append(participant.stored) 

404 resources.add(participant.stored.resource) 

405 

406 with self.xmpp.store.session(expire_on_commit=False) as orm: 

407 orm.add(self.stored) 

408 # because self.fill_participants() is async, self.stored may be stale at 

409 # this point, and the only thing we want to update is the participant list 

410 # and the participant_filled attribute. 

411 with orm.no_autoflush: 

412 orm.refresh(self.stored, ["participants"]) 

413 for part in parts: 

414 orm.merge(part) 

415 self.stored.participants_filled = True 

416 orm.commit() 

417 

418 self.log.debug("Filling participants: done") 

419 

420 async def get_participants( 

421 self, affiliation: MucAffiliation | None = None 

422 ) -> AsyncIterator[LegacyParticipantType]: 

423 await self.__fill_participants() 

424 with self.xmpp.store.session(expire_on_commit=False, autoflush=False) as orm: 

425 self.stored = orm.merge(self.stored) 

426 db_participants = self.stored.participants 

427 for db_participant in db_participants: 

428 if affiliation is not None and db_participant.affiliation != affiliation: 

429 continue 

430 yield self.participant_from_store(db_participant) 

431 

432 async def __fill_history(self) -> None: 

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

434 with self.xmpp.store.session(expire_on_commit=False) as orm: 

435 orm.add(self.stored) 

436 with orm.no_autoflush: 

437 orm.refresh(self.stored, ["history_filled"]) 

438 if self.stored.history_filled: 

439 self.log.debug("History has already been fetched.") 

440 return 

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

442 try: 

443 await self.backfill(*self.archive.get_hole_bounds()) 

444 except NotImplementedError: 

445 return 

446 except Exception as e: 

447 self.log.exception("Could not backfill", exc_info=e) 

448 

449 self.stored.history_filled = True 

450 self.commit() 

451 

452 def _get_disco_name(self) -> str | None: 

453 return self.name 

454 

455 @property 

456 def name(self) -> str | None: 

457 return self.stored.name 

458 

459 @name.setter 

460 def name(self, n: str | None) -> None: 

461 if self.name == n: 

462 return 

463 self.update_stored_attribute(name=n) 

464 self._set_logger() 

465 self.__send_configuration_change((104,)) 

466 

467 @property 

468 def description(self) -> str: 

469 self.stored.ensure_loaded("disco", self.xmpp.store.session) 

470 return self.stored.description or "" 

471 

472 @description.setter 

473 def description(self, d: str | None) -> None: 

474 d = d or "" 

475 if self.description == d: 

476 return 

477 self.update_stored_attribute(description=d) 

478 self.__send_configuration_change((104,)) 

479 

480 def on_presence_unavailable(self, p: Presence) -> None: 

481 pto = p.get_to() 

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

483 return 

484 

485 pfrom = p.get_from() 

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

487 return 

488 if (resource := pfrom.resource) in self.get_user_resources(): 

489 if pto.resource != self.user_nick: 

490 self.log.debug( 

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

492 ) 

493 self.remove_user_resource(resource) 

494 else: 

495 self.log.debug( 

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

497 ) 

498 

499 async def update_info(self) -> None: 

500 """ 

501 Fetch information about this group from the legacy network 

502 

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

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

505 of participants etc. 

506 

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

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

509 is no change, you should not call 

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

511 attempt to modify 

512 the :attr:.avatar property. 

513 

514 :raises XMPPError: MUST be raised when the legacy group does not exist. 

515 """ 

516 raise NotImplementedError 

517 

518 async def backfill( 

519 self, 

520 after: HoleBound | None = None, 

521 before: HoleBound | None = None, 

522 ) -> None: 

523 """ 

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

525 

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

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

528 run for a given group. 

529 

530 :param after: Fetch messages after this one. 

531 If ``None``, slidge's local archive was empty before start-up, 

532 ie, no history was ever fetched for this room since the user registered. 

533 It's up to gateway implementations to decide how far to fetch messages before 

534 the user registered. 

535 If not ``None``, slidge has some messages in this archive, and 

536 the gateway shall try to fetch history up to (and excluding) this message 

537 to avoid "holes" in the history of this group. 

538 :param before: Fetch messages before this one. 

539 If ``None``, the gateway shall fetch all messages up to the most recent one. 

540 If not ``None``, slidge has already archived some live messages 

541 it received during its lifetime, and there is no need to query the legacy 

542 network for any message after (and including) this one. 

543 """ 

544 raise NotImplementedError 

545 

546 async def fill_participants(self) -> AsyncIterator[LegacyParticipantType]: 

547 """ 

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

549 

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

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

552 before yielding them. 

553 """ 

554 return 

555 yield 

556 

557 @property 

558 def subject(self) -> str: 

559 self.stored.ensure_loaded("subject", self.xmpp.store.session) 

560 return self.stored.subject or "" 

561 

562 @subject.setter 

563 def subject(self, s: str) -> None: 

564 if s == self.subject: 

565 return 

566 

567 self.update_stored_attribute(subject=s) 

568 self.__get_subject_setter_participant().set_room_subject( 

569 s, None, self.subject_date, False 

570 ) 

571 

572 @property 

573 def is_anonymous(self) -> bool: 

574 return self.type == MucType.CHANNEL 

575 

576 @property 

577 def subject_setter(self) -> str | None: 

578 self.stored.ensure_loaded("subject", self.xmpp.store.session) 

579 return self.stored.subject_setter 

580 

581 @subject_setter.setter 

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

583 if isinstance(subject_setter, LegacyContact): 

584 subject_setter = subject_setter.name 

585 elif isinstance(subject_setter, LegacyParticipant): 

586 subject_setter = subject_setter.nickname 

587 

588 if subject_setter == self.subject_setter: 

589 return 

590 assert isinstance(subject_setter, str | None) 

591 self.update_stored_attribute(subject_setter=subject_setter) 

592 

593 def __get_subject_setter_participant(self) -> AnyParticipant: 

594 if self.subject_setter is None: 

595 return self.get_system_participant() 

596 return self.participant_cls( 

597 self, 

598 Participant(nickname=self.subject_setter, occupant_id="subject-setter"), 

599 ) 

600 

601 def features(self) -> list[str]: 

602 features = [ 

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

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

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

606 "urn:xmpp:mam:2", 

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

608 "urn:xmpp:sid:0", 

609 "muc_persistent", 

610 "vcard-temp", 

611 "urn:xmpp:ping", 

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

613 "jabber:iq:register", 

614 "http://jabber.org/protocol/commands", 

615 "urn:xmpp:muc:affiliations:1", 

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

617 ] 

618 if self.type == MucType.GROUP: 

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

620 elif self.type == MucType.CHANNEL: 

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

622 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

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

624 

625 if self.stored.space_id is not None: 

626 features.append("urn:xmpp:spaces:0") 

627 return features 

628 

629 async def extended_features(self) -> list[Form]: 

630 is_group = self.type == MucType.GROUP 

631 

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

633 

634 form.add_field( 

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

636 ) 

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

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

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

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

641 

642 self.stored.ensure_loaded("disco", self.xmpp.store.session) 

643 

644 if self.stored.id is not None and self.participants_filled: 

645 with self.xmpp.store.session() as orm: 

646 n = orm.scalar( 

647 sa.select(sa.func.count(Participant.id)).filter_by( 

648 room_id=self.stored.id 

649 ) 

650 ) 

651 else: 

652 n = self.stored.n_participants 

653 

654 if n is not None: 

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

656 

657 if d := self.stored.description: 

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

659 

660 if s := self.subject: 

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

662 

663 if name := self.name: 

664 form.add_field("muc#roomconfig_roomname", value=name) 

665 

666 if self._set_avatar_task is not None: 

667 await self._set_avatar_task 

668 avatar = self.get_avatar() 

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

670 form.add_field( 

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

672 ) 

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

674 if avatar.url: 

675 form.add_field("{http://slidge.im}/avatar#url", value=avatar.url) 

676 

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

678 form.add_field( 

679 "muc#roomconfig_whois", 

680 "list-single", 

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

682 ) 

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

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

685 

686 r = [form] 

687 

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

689 r.append(reaction_form) 

690 

691 if self.stored.space_id is not None: 

692 stored = self.__stored_space() 

693 assert stored is not None 

694 node = await self.session.bookmarks.space_legacy_id_to_node( 

695 stored.legacy_id 

696 ) 

697 iri = f"xmpp:{self.xmpp.boundjid.bare}?node={node}" 

698 form.add_field("muc#roominfo_pubsub", value=iri) 

699 space_form = self.xmpp.plugin["xep_0004"].make_form(ftype="result") 

700 space_form.add_field("FORM_TYPE", "hidden", value="urn:xmpp:spaces:0") 

701 space_form.add_field("parent", label="Space parent", value=iri) 

702 

703 return r 

704 

705 def shutdown(self) -> None: 

706 _, user_jid = escape_nickname(self.jid, self.user_nick) 

707 for user_full_jid in self.user_full_jids(): 

708 presence = self.xmpp.make_presence( 

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

710 ) 

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

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

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

714 presence.send() 

715 

716 def user_full_jids(self) -> Iterable[JID]: 

717 for r in self.get_user_resources(): 

718 j = JID(self.user_jid) 

719 j.resource = r 

720 yield j 

721 

722 @property 

723 def user_muc_jid(self) -> JID: 

724 _, user_muc_jid = escape_nickname(self.jid, self.user_nick) 

725 return user_muc_jid 

726 

727 async def echo(self, msg: Message, legacy_msg_id: str | None = None) -> str: 

728 msg.set_from(self.user_muc_jid) 

729 if legacy_msg_id: 

730 msg["stanza_id"]["id"] = legacy_msg_id 

731 else: 

732 msg["stanza_id"]["id"] = str(uuid.uuid4()) 

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

734 

735 user_part = await self.get_user_participant() 

736 msg["occupant-id"]["id"] = user_part.stored.occupant_id 

737 

738 self.archive.add(msg, user_part, legacy_msg_id=legacy_msg_id) 

739 

740 for user_full_jid in self.user_full_jids(): 

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

742 msg = copy(msg) 

743 msg.set_to(user_full_jid) 

744 

745 msg.send() 

746 

747 return msg["stanza_id"]["id"] # type:ignore[no-any-return] 

748 

749 def _post_avatar_update(self, cached_avatar: "CachedAvatar | None") -> None: 

750 self.__send_configuration_change((104,)) 

751 self._send_room_presence() 

752 

753 def _send_room_presence(self, user_full_jid: JID | None = None) -> None: 

754 tos = self.user_full_jids() if user_full_jid is None else [user_full_jid] 

755 for to in tos: 

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

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

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

759 if avatar.http_metadata is not None: 

760 metadata = self.xmpp.plugin["xep_0084"].stanza.MetaData() 

761 metadata.append(avatar.http_metadata) 

762 p.append(metadata) 

763 else: 

764 p["vcard_temp_update"]["photo"] = "" 

765 p.send() 

766 

767 @timeit 

768 async def join(self, join_presence: Presence) -> None: 

769 user_full_jid = join_presence.get_from() 

770 requested_nickname = join_presence.get_to().resource 

771 client_resource = user_full_jid.resource 

772 

773 if client_resource in self.get_user_resources(): 

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

775 

776 if not requested_nickname or not client_resource: 

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

778 

779 self.add_user_resource(client_resource) 

780 

781 self.log.debug( 

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

783 client_resource, 

784 self.user_jid, 

785 self.legacy_id, 

786 requested_nickname, 

787 ) 

788 

789 await self.__fill_participants() 

790 if "mav" in join_presence["muc_join"]: 

791 mav_until = await self.__get_mav() 

792 self.log.debug("client uses MUC affiliation versioning") 

793 if join_presence["muc_join"]["mav"]["since"] != mav_until: 

794 self.log.debug( 

795 "client mav: %s vs our mav: %s", 

796 join_presence["muc_join"]["mav"]["since"], 

797 mav_until, 

798 ) 

799 await self.__send_mav(user_full_jid, mav_until) 

800 else: 

801 mav_until = None 

802 

803 self.log.debug("Sending presences for join of %s", client_resource) 

804 

805 self.stored.load_participants(self.xmpp.store.session, with_presence=True) 

806 user_participant = None 

807 

808 part_pks = [] 

809 for db_participant in self.stored.participants: 

810 participant = self.participant_from_store(db_participant, for_join=True) 

811 if participant.is_user: 

812 user_participant = participant 

813 continue 

814 participant.send_initial_presence(full_jid=user_full_jid) 

815 part_pks.append(db_participant.id) 

816 

817 with self.xmpp.store.session(expire_on_commit=False) as orm: 

818 orm.execute( 

819 sa.update(Participant) 

820 .where(Participant.id.in_(part_pks)) 

821 .values(presence_sent=True) 

822 ) 

823 orm.commit() 

824 

825 if user_participant is None: 

826 user_participant = await self.get_user_participant() 

827 with self.xmpp.store.session() as orm: 

828 orm.add(self.stored) 

829 with orm.no_autoflush: 

830 orm.refresh(self.stored, ["participants"]) 

831 if not user_participant.is_user: 

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

833 user_participant.is_user = True 

834 user_participant.send_initial_presence( 

835 user_full_jid, 

836 presence_id=join_presence["id"], 

837 nick_change=self.user_nick != requested_nickname, 

838 mav_until=mav_until, 

839 ) 

840 

841 self.log.debug("Sending presences for join of %s: DONE", client_resource) 

842 

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

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

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

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

847 try: 

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

849 except ValueError: 

850 since = None 

851 if seconds is not None: 

852 since = datetime.now(tz=UTC) - timedelta(seconds=seconds) 

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

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

855 else: 

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

857 await self.__fill_history() 

858 await self.__old_school_history( 

859 user_full_jid, 

860 maxchars=maxchars, 

861 maxstanzas=maxstanzas, 

862 since=since, 

863 ) 

864 if self.HAS_SUBJECT: 

865 subject = self.subject or "" 

866 else: 

867 subject = self.description or self.name or "" 

868 self.__get_subject_setter_participant().set_room_subject( 

869 subject, 

870 user_full_jid, 

871 self.subject_date, 

872 ) 

873 if t := self._set_avatar_task: 

874 await t 

875 self._send_room_presence(user_full_jid) 

876 

877 async def __get_mav(self) -> str: 

878 data = self.__get_mav_data() 

879 return self.__compute_mav_ver(data) 

880 

881 def __get_mav_data(self) -> list[tuple[str, MucAffiliation]]: 

882 data: list[tuple[str, MucAffiliation]] = [] 

883 self.stored.load_participants(self.orm, with_presence=False) 

884 for part in self.stored.participants: 

885 if not (part.is_user or part.contact): 

886 continue 

887 if part.affiliation == "none": 

888 continue 

889 data.append((self.__get_part_mav_id(part), part.affiliation)) 

890 return data 

891 

892 def __get_part_mav_id(self, part: "LegacyParticipantType | Participant") -> str: 

893 return str(self.user_jid if part.is_user else part.contact.legacy_id) # type:ignore[union-attr] # ty:ignore[unresolved-attribute] 

894 

895 def __compute_mav_ver(self, data: list[tuple[Any, MucAffiliation]]) -> str: 

896 self.log.debug("MAV data: %s", data) 

897 affs = [] 

898 for id_, aff in data: 

899 if aff == "none": 

900 continue 

901 affs.append(f"{id_}\0{aff}".encode()) 

902 affs.sort() 

903 return hashlib.sha256(b"\0".join(affs)).hexdigest() 

904 

905 async def __send_mav(self, full_jid: JID, until: str) -> None: 

906 msg = self.xmpp.make_message(mto=full_jid, mfrom=self.jid) 

907 msg["muc"]["mav"]["until"] = until 

908 with self.orm(expire_on_commit=False) as orm: 

909 self.stored = orm.merge(self.stored) 

910 for part in self.stored.participants: 

911 if not (part.is_user or part.contact): 

912 continue 

913 item = MUCUserItem() 

914 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare # type:ignore[union-attr] 

915 item["affiliation"] = part.affiliation 

916 msg["muc"].append(item) 

917 msg.send() 

918 

919 async def get_user_participant( 

920 self, 

921 *, 

922 fill_first: bool = False, 

923 store: bool = True, 

924 occupant_id: str | None = None, 

925 ) -> "LegacyParticipantType": 

926 """ 

927 Get the participant representing the gateway user 

928 

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

930 construction (optional) 

931 :return: 

932 """ 

933 p = await self.get_participant( 

934 self.user_nick, 

935 is_user=True, 

936 fill_first=fill_first, 

937 store=store, 

938 occupant_id=occupant_id, 

939 ) 

940 self.__store_participant(p) 

941 return p 

942 

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

944 if self.get_lock("fill participants"): 

945 return 

946 try: 

947 p.commit() 

948 except IntegrityError as e: 

949 log.debug("Could not store participant: %r", e) 

950 

951 @overload 

952 async def get_participant(self, nickname: str) -> "LegacyParticipantType": ... 

953 

954 @overload 

955 async def get_participant( 

956 self, nickname: str, *, store: bool 

957 ) -> "LegacyParticipantType": ... 

958 

959 @overload 

960 async def get_participant(self, *, occupant_id: str) -> "LegacyParticipantType": ... 

961 

962 @overload 

963 async def get_participant( 

964 self, *, occupant_id: str, create: Literal[False] 

965 ) -> "LegacyParticipantType | None": ... 

966 

967 @overload 

968 async def get_participant( 

969 self, nickname: str, *, occupant_id: str, create: bool 

970 ) -> "LegacyParticipantType | None": ... 

971 

972 @overload 

973 async def get_participant( 

974 self, *, occupant_id: str, create: Literal[True] 

975 ) -> "LegacyParticipantType": ... 

976 

977 @overload 

978 async def get_participant( 

979 self, nickname: str, *, occupant_id: str 

980 ) -> "LegacyParticipantType": ... 

981 

982 @overload 

983 async def get_participant( 

984 self, nickname: str, *, create: Literal[False] 

985 ) -> "LegacyParticipantType | None": ... 

986 

987 @overload 

988 async def get_participant( 

989 self, nickname: str, *, create: Literal[True] 

990 ) -> "LegacyParticipantType": ... 

991 

992 @overload 

993 async def get_participant( 

994 self, 

995 nickname: str, 

996 *, 

997 create: Literal[True], 

998 is_user: bool, 

999 fill_first: bool, 

1000 store: bool, 

1001 ) -> "LegacyParticipantType": ... 

1002 

1003 @overload 

1004 async def get_participant( 

1005 self, 

1006 nickname: str, 

1007 *, 

1008 create: Literal[False], 

1009 is_user: bool, 

1010 fill_first: bool, 

1011 store: bool, 

1012 ) -> "LegacyParticipantType | None": ... 

1013 

1014 @overload 

1015 async def get_participant( 

1016 self, 

1017 nickname: str, 

1018 *, 

1019 create: bool, 

1020 fill_first: bool, 

1021 ) -> "LegacyParticipantType | None": ... 

1022 

1023 @overload 

1024 async def get_participant( 

1025 self, 

1026 nickname: str, 

1027 *, 

1028 is_user: Literal[True], 

1029 fill_first: bool, 

1030 store: bool, 

1031 occupant_id: str | None = None, 

1032 ) -> "LegacyParticipantType": ... 

1033 

1034 async def get_participant( 

1035 self, 

1036 nickname: str | None = None, 

1037 *, 

1038 create: bool = True, 

1039 is_user: bool = False, 

1040 fill_first: bool = False, 

1041 store: bool = True, 

1042 occupant_id: str | None = None, 

1043 ) -> "LegacyParticipantType | None": 

1044 """ 

1045 Get a participant by their nickname. 

1046 

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

1048 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

1049 

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

1051 :param create: By default, a participant is created if necessary. Set this to 

1052 False to return None if participant was not created before. 

1053 :param is_user: Whether this participant is the slidge user. 

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

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

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

1057 :param occupant_id: optionally, specify the unique ID for this participant, cf 

1058 xep:`0421` 

1059 :return: A participant of this room. 

1060 """ 

1061 if not any((nickname, occupant_id)): 

1062 raise TypeError("You must specify either a nickname or an occupant ID") 

1063 if fill_first: 

1064 await self.__fill_participants() 

1065 if self.stored.id is not None: 

1066 with self.xmpp.store.session(expire_on_commit=False) as orm: 

1067 if occupant_id is not None: 

1068 stored = ( 

1069 orm.query(Participant) 

1070 .filter( 

1071 Participant.room == self.stored, 

1072 Participant.occupant_id == occupant_id, 

1073 ) 

1074 .one_or_none() 

1075 ) 

1076 elif nickname is not None: 

1077 stored = ( 

1078 orm.query(Participant) 

1079 .filter( 

1080 Participant.room == self.stored, 

1081 (Participant.nickname == nickname) 

1082 | (Participant.resource == nickname), 

1083 ) 

1084 .one_or_none() 

1085 ) 

1086 else: 

1087 raise RuntimeError("NEVER") 

1088 if stored is not None: 

1089 if occupant_id and occupant_id != stored.occupant_id: 

1090 warnings.warn( 

1091 f"Occupant ID mismatch in get_participant(): {occupant_id} vs {stored.occupant_id}", 

1092 ) 

1093 part = self.participant_from_store(stored) 

1094 if occupant_id and nickname and nickname != stored.nickname: 

1095 stored.nickname = nickname 

1096 orm.add(stored) 

1097 orm.commit() 

1098 return part 

1099 

1100 if not create: 

1101 return None 

1102 

1103 if occupant_id is None: 

1104 occupant_id = "slidge-user" if is_user else str(uuid.uuid4()) 

1105 

1106 if nickname is None: 

1107 nickname = occupant_id 

1108 

1109 with self.xmpp.store.session() as orm: 

1110 if not self.xmpp.store.rooms.nick_available(orm, self.stored.id, nickname): 

1111 nickname = f"{nickname} ({occupant_id})" 

1112 if is_user: 

1113 self.user_nick = nickname 

1114 

1115 p = self.participant_cls( 

1116 self, 

1117 Participant( 

1118 room=self.stored, 

1119 nickname=nickname or occupant_id, 

1120 is_user=is_user, 

1121 occupant_id=occupant_id, 

1122 ), 

1123 ) 

1124 if store: 

1125 self.__store_participant(p) 

1126 self.send_affiliation_change(p) 

1127 return p 

1128 

1129 def send_affiliation_change( 

1130 self, part: "LegacyParticipantType", was: MucAffiliation = "none" 

1131 ) -> None: 

1132 # internal use by slidge 

1133 if not self.participants_filled: 

1134 return 

1135 if self.get_lock("fill participants"): 

1136 return 

1137 if self.get_lock("fill history"): 

1138 return 

1139 if part.contact is None: 

1140 return 

1141 if part.is_system: 

1142 return 

1143 if was == part.affiliation: 

1144 return 

1145 system_part = self.get_system_participant() 

1146 msg = system_part._make_message(mtype="normal") 

1147 until = self.__get_mav_data() 

1148 since = [ 

1149 (id_, was if id_ == self.__get_part_mav_id(part) else aff) 

1150 for id_, aff in until 

1151 ] 

1152 if part.affiliation == "none": 

1153 since.append((self.__get_part_mav_id(part), was)) 

1154 msg["muc"]["mav"]["since"] = self.__compute_mav_ver(since) 

1155 msg["muc"]["mav"]["until"] = self.__compute_mav_ver(until) 

1156 item = MUCUserItem() 

1157 item["affiliation"] = part.affiliation 

1158 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare 

1159 msg["muc"].append(item) 

1160 system_part._send(msg) 

1161 

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

1163 """ 

1164 Get a pseudo-participant, representing the room itself 

1165 

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

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

1168 service 

1169 :return: 

1170 """ 

1171 return self.participant_cls( 

1172 self, Participant(occupant_id="room"), is_system=True 

1173 ) 

1174 

1175 @overload 

1176 async def get_participant_by_contact( 

1177 self, c: "LegacyContact" 

1178 ) -> "LegacyParticipantType": ... 

1179 

1180 @overload 

1181 async def get_participant_by_contact( 

1182 self, c: "LegacyContact", *, occupant_id: str | None = None 

1183 ) -> "LegacyParticipantType": ... 

1184 

1185 @overload 

1186 async def get_participant_by_contact( 

1187 self, 

1188 c: "LegacyContact", 

1189 *, 

1190 create: Literal[False], 

1191 occupant_id: str | None, 

1192 ) -> "LegacyParticipantType | None": ... 

1193 

1194 @overload 

1195 async def get_participant_by_contact( 

1196 self, 

1197 c: "LegacyContact", 

1198 *, 

1199 create: Literal[True], 

1200 occupant_id: str | None, 

1201 ) -> "LegacyParticipantType": ... 

1202 

1203 async def get_participant_by_contact( 

1204 self, c: LegacyContact, *, create: bool = True, occupant_id: str | None = None 

1205 ) -> "LegacyParticipantType | None": 

1206 """ 

1207 Get a non-anonymous participant. 

1208 

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

1210 that the Contact jid is associated to this participant 

1211 

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

1213 :param create: Creates the participant if it does not exist. 

1214 :param occupant_id: Optionally, specify a unique occupant ID (:xep:`0421`) for 

1215 this participant. 

1216 :return: 

1217 """ 

1218 await self.session.contacts.ready 

1219 

1220 if self.stored.id is not None: 

1221 with self.xmpp.store.session() as orm: 

1222 self.stored = orm.merge(self.stored) 

1223 stored = ( 

1224 orm.query(Participant) 

1225 .filter_by(contact=c.stored, room=self.stored) 

1226 .one_or_none() 

1227 ) 

1228 if stored is None: 

1229 if occupant_id is not None: 

1230 stored = ( 

1231 orm.query(Participant) 

1232 .filter_by( 

1233 occupant_id=occupant_id, 

1234 room=self.stored, 

1235 contact_id=None, 

1236 ) 

1237 .one_or_none() 

1238 ) 

1239 if stored is not None: 

1240 self.log.debug( 

1241 "Updating the contact of a previously anonymous participant" 

1242 ) 

1243 stored.contact_id = c.stored.id 

1244 orm.add(stored) 

1245 orm.commit() 

1246 return self.participant_from_store(stored=stored, contact=c) 

1247 if not create: 

1248 return None 

1249 else: 

1250 if occupant_id and stored.occupant_id != occupant_id: 

1251 warnings.warn( 

1252 f"Occupant ID mismatch: {occupant_id} vs {stored.occupant_id}", 

1253 ) 

1254 return self.participant_from_store(stored=stored, contact=c) 

1255 

1256 nickname = c.name or unescape_node(c.jid.node) 

1257 

1258 if self.stored.id is None: 

1259 nick_available = True 

1260 else: 

1261 with self.xmpp.store.session() as orm: 

1262 nick_available = self.xmpp.store.rooms.nick_available( 

1263 orm, self.stored.id, nickname 

1264 ) 

1265 

1266 if not nick_available: 

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

1268 nickname = f"{nickname} ({unescape_node(c.jid.node)})" 

1269 p = self.participant_cls( 

1270 self, 

1271 Participant( 

1272 nickname=nickname, 

1273 room=self.stored, 

1274 occupant_id=occupant_id or str(c.jid), 

1275 ), 

1276 contact=c, 

1277 ) 

1278 

1279 self.__store_participant(p) 

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

1281 # during participants fill and history backfill we do not 

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

1283 # and role afterwards. 

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

1285 if ( 

1286 self.participants_filled 

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

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

1289 ): 

1290 self.send_affiliation_change(p) 

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

1292 return p 

1293 

1294 @overload 

1295 async def get_participant_by_legacy_id( 

1296 self, legacy_id: str 

1297 ) -> "LegacyParticipantType": ... 

1298 

1299 @overload 

1300 async def get_participant_by_legacy_id( 

1301 self, 

1302 legacy_id: str, 

1303 *, 

1304 occupant_id: str | None, 

1305 create: Literal[True], 

1306 ) -> "LegacyParticipantType": ... 

1307 

1308 @overload 

1309 async def get_participant_by_legacy_id( 

1310 self, 

1311 legacy_id: str, 

1312 *, 

1313 occupant_id: str | None, 

1314 ) -> "LegacyParticipantType": ... 

1315 

1316 @overload 

1317 async def get_participant_by_legacy_id( 

1318 self, 

1319 legacy_id: str, 

1320 *, 

1321 occupant_id: str | None, 

1322 create: Literal[False], 

1323 ) -> "LegacyParticipantType | None": ... 

1324 

1325 async def get_participant_by_legacy_id( 

1326 self, 

1327 legacy_id: str, 

1328 *, 

1329 occupant_id: str | None = None, 

1330 create: bool = True, 

1331 ) -> "LegacyParticipantType": 

1332 try: 

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

1334 except ContactIsUser: 

1335 return await self.get_user_participant(occupant_id=occupant_id) 

1336 return await self.get_participant_by_contact( # type:ignore[call-overload,no-any-return] 

1337 c, create=create, occupant_id=occupant_id 

1338 ) 

1339 

1340 def remove_participant( 

1341 self, 

1342 p: "LegacyParticipantType", 

1343 kick: bool = False, 

1344 ban: bool = False, 

1345 reason: str | None = None, 

1346 ) -> None: 

1347 """ 

1348 Call this when a participant leaves the room 

1349 

1350 :param p: The participant 

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

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

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

1354 """ 

1355 self.log.debug("Removing participant: %s", p) 

1356 if kick and ban: 

1357 raise TypeError("Either kick or ban") 

1358 if kick: 

1359 codes = {307} 

1360 elif ban: 

1361 codes = {301} 

1362 else: 

1363 codes = None 

1364 was = p.stored.affiliation 

1365 p.stored.affiliation = "outcast" if ban else "none" 

1366 p.stored.role = "none" 

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

1368 with self.orm() as orm: 

1369 self.xmpp.store.participants.delete(orm, p.stored.id) 

1370 orm.commit() 

1371 self.send_affiliation_change(p, was) 

1372 if reason: 

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

1374 p._send(presence, force=True) 

1375 

1376 def rename_participant(self, old_nickname: str, new_nickname: str) -> None: 

1377 with self.xmpp.store.session() as orm: 

1378 stored = ( 

1379 orm.query(Participant) 

1380 .filter_by(room=self.stored, nickname=old_nickname) 

1381 .one_or_none() 

1382 ) 

1383 if stored is None: 

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

1385 return 

1386 p = self.participant_from_store(stored) 

1387 if p.nickname == old_nickname: 

1388 p.nickname = new_nickname 

1389 

1390 async def __old_school_history( 

1391 self, 

1392 full_jid: JID, 

1393 maxchars: int | None = None, 

1394 maxstanzas: int | None = None, 

1395 seconds: int | None = None, 

1396 since: datetime | None = None, 

1397 ) -> None: 

1398 """ 

1399 Old-style history join (internal slidge use) 

1400 

1401 :param full_jid: 

1402 :param maxchars: 

1403 :param maxstanzas: 

1404 :param seconds: 

1405 :param since: 

1406 :return: 

1407 """ 

1408 if since is None: 

1409 if seconds is None: 

1410 start_date = datetime.now(tz=UTC) - timedelta(days=1) 

1411 else: 

1412 start_date = datetime.now(tz=UTC) - timedelta(seconds=seconds) 

1413 else: 

1414 start_date = since or datetime.now(tz=UTC) - timedelta(days=1) 

1415 

1416 for h_msg in self.archive.get_all( 

1417 start_date=start_date, end_date=None, last_page_n=maxstanzas 

1418 ): 

1419 msg = h_msg.stanza("jabber:component:accept", user_jid=self.user_jid.bare) 

1420 msg["delay"]["stamp"] = h_msg.timestamp.replace(tzinfo=UTC) 

1421 msg["delay"]["from"] = self.jid 

1422 msg.set_to(full_jid) 

1423 self.xmpp.send(msg, False) 

1424 

1425 async def send_mam(self, iq: Iq) -> None: 

1426 await self.__fill_history() 

1427 

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

1429 

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

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

1432 

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

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

1435 

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

1437 

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

1439 try: 

1440 max_results = int(max_str) 

1441 except ValueError: 

1442 max_results = None 

1443 else: 

1444 max_results = None 

1445 

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

1447 after_id = after_id_rsm or after_id 

1448 

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

1450 if before_rsm is not None and max_results is not None: 

1451 last_page_n = max_results 

1452 # - before_rsm is True means the empty element <before />, which means 

1453 # "last page in chronological order", cf https://xmpp.org/extensions/xep-0059.html#backwards 

1454 # - before_rsm == "an ID" means <before>an ID</before> 

1455 if before_rsm is not True: 

1456 before_id = before_rsm 

1457 else: 

1458 last_page_n = None 

1459 

1460 first = None 

1461 last = None 

1462 count = 0 

1463 

1464 sender = form_values.get("with") 

1465 sender_resource = JID(sender).resource if sender else None 

1466 

1467 it = self.archive.get_all( 

1468 start_date, 

1469 end_date, 

1470 before_id, 

1471 after_id, 

1472 ids, 

1473 last_page_n, 

1474 sender_resource, 

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

1476 ) 

1477 

1478 for history_msg in it: 

1479 last = xmpp_id = history_msg.stanza_id 

1480 if first is None: 

1481 first = xmpp_id 

1482 

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

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

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

1486 wrapper_msg["mam_result"].append( 

1487 history_msg.forwarded(user_jid=self.user_jid.bare) 

1488 ) 

1489 

1490 wrapper_msg.send() 

1491 count += 1 

1492 

1493 if max_results and count == max_results: 

1494 break 

1495 

1496 if max_results: 

1497 try: 

1498 next(it) 

1499 except StopIteration: 

1500 complete = True 

1501 else: 

1502 complete = False 

1503 else: 

1504 complete = True 

1505 

1506 reply = iq.reply() 

1507 if not self.STABLE_ARCHIVE: 

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

1509 if complete: 

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

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

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

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

1514 reply.send() 

1515 

1516 async def send_mam_metadata(self, iq: Iq) -> None: 

1517 await self.__fill_history() 

1518 await self.archive.send_metadata(iq) 

1519 

1520 async def kick_resource(self, r: str) -> None: 

1521 """ 

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

1523 

1524 :param r: The resource to kick 

1525 """ 

1526 pto = JID(self.user_jid) 

1527 pto.resource = r 

1528 p = self.xmpp.make_presence( 

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

1530 ) 

1531 p["type"] = "unavailable" 

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

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

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

1535 p.send() 

1536 

1537 async def __get_bookmark(self) -> Item | None: 

1538 item = Item() 

1539 item["id"] = self.jid 

1540 

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

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

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

1544 

1545 try: 

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

1547 if len(ans["pubsub"]["items"]) != 1: 

1548 return None 

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

1550 # (slixmpp annoying magic) 

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

1552 item["id"] = self.jid 

1553 return item # type:ignore[no-any-return] 

1554 except IqTimeout: 

1555 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: timeout") 

1556 return None 

1557 except IqError as exc: 

1558 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: {exc}") 

1559 return None 

1560 except PermissionError: 

1561 warnings.warn( 

1562 f"IQ privileges (XEP0356) not granted for {self.user_jid}, we cannot fetch the user bookmarks" 

1563 ) 

1564 return None 

1565 

1566 async def add_to_bookmarks( 

1567 self, 

1568 auto_join: bool = True, 

1569 preserve: bool = True, 

1570 pin: bool | None = None, 

1571 notify: WhenLiteral | None = None, 

1572 ) -> None: 

1573 """ 

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

1575 

1576 This requires that slidge has the IQ privileged set correctly 

1577 on the XMPP server 

1578 

1579 :param auto_join: whether XMPP clients should automatically join 

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

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

1582 join if they are online. 

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

1584 set by the user outside slidge 

1585 :param pin: Pin the group chat bookmark :xep:`0469`. Requires privileged entity. 

1586 If set to ``None`` (default), the bookmark pinning status will be untouched. 

1587 :param notify: Chat notification setting: :xep:`0492`. Requires privileged entity. 

1588 If set to ``None`` (default), the setting will be untouched. Only the "global" 

1589 notification setting is supported (ie, per client type is not possible). 

1590 """ 

1591 existing = await self.__get_bookmark() if preserve else None 

1592 user_resource = (await self.get_user_participant()).jid.resource 

1593 

1594 new = Item() 

1595 new["id"] = self.jid 

1596 new["conference"]["nick"] = user_resource 

1597 

1598 invite = self.session.user.preferences.get( 

1599 "always_invite_when_adding_bookmarks", True 

1600 ) 

1601 if existing is None: 

1602 change = True 

1603 new["conference"]["autojoin"] = auto_join 

1604 else: 

1605 change = existing["conference"]["nick"] != user_resource 

1606 if not existing["conference"]["autojoin"]: 

1607 invite = False 

1608 new["conference"]["autojoin"] = existing["conference"]["autojoin"] 

1609 

1610 existing_extensions = ( 

1611 existing is not None and "extensions" in existing["conference"] 

1612 ) 

1613 

1614 # preserving extensions we don't know about is a MUST 

1615 if existing_extensions: 

1616 assert existing is not None 

1617 for el in existing["conference"]["extensions"].xml: 

1618 if el.tag.startswith(f"{{{NOTIFY_NS}}}") and notify is not None: 

1619 continue 

1620 if el.tag.startswith(f"{{{PINNING_NS}}}") and pin is not None: 

1621 continue 

1622 new["conference"]["extensions"].append(el) 

1623 

1624 if pin is not None: 

1625 if existing_extensions: 

1626 assert existing is not None 

1627 existing_pin = ( 

1628 existing["conference"]["extensions"].get_plugin( 

1629 "pinned", check=True 

1630 ) 

1631 is not None 

1632 ) 

1633 if existing_pin != pin: 

1634 change = True 

1635 new["conference"]["extensions"]["pinned"] = pin 

1636 

1637 if notify is not None: 

1638 new["conference"]["extensions"].enable("notify") 

1639 if existing_extensions: 

1640 assert existing is not None 

1641 existing_notify = existing["conference"]["extensions"].get_plugin( 

1642 "notify", check=True 

1643 ) 

1644 if existing_notify is None: 

1645 change = True 

1646 else: 

1647 if existing_notify.get_config() != notify: 

1648 change = True 

1649 for el in existing_notify: 

1650 new["conference"]["extensions"]["notify"].append(el) 

1651 new["conference"]["extensions"]["notify"].configure(notify) 

1652 

1653 if change: 

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

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

1656 iq["pubsub"]["publish"].append(new) 

1657 

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

1659 

1660 update_success = False 

1661 try: 

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

1663 except PermissionError: 

1664 warnings.warn( 

1665 f"IQ privileges (XEP0356) not granted for {self.user_jid}, we cannot add bookmarks for the user" 

1666 ) 

1667 except IqError as e: 

1668 warnings.warn( 

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

1670 ) 

1671 else: 

1672 update_success = True 

1673 if existing is None and not update_success and not invite: 

1674 self.session.send_gateway_invite( 

1675 self, 

1676 reason="This group could not be added automatically for you, most " 

1677 "likely because this gateway is not configured as a privileged entity. " 

1678 "Contact your administrator.", 

1679 ) 

1680 return 

1681 else: 

1682 self.log.debug("Bookmark does not need updating.") 

1683 

1684 if invite: 

1685 self.session.send_gateway_invite( 

1686 self.jid, 

1687 reason="The gateway is configured to send invitations for groups.", 

1688 ) 

1689 

1690 async def remove_from_bookmarks(self) -> None: 

1691 """Remove the MUC from the user's XMPP bookmarks (:xep:`0402`). 

1692 

1693 Does not actually leave the room on the legacy network, nor apply any other 

1694 side-effect. 

1695 

1696 Requires that slidge has the IQ privileged set correctly on the XMPP server. 

1697 """ 

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

1699 iq["pubsub"]["retract"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS 

1700 iq["pubsub"]["retract"]["notify"] = "true" 

1701 iq["pubsub"]["retract"]["item"]["id"] = str(self.jid) 

1702 try: 

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

1704 except PermissionError: 

1705 warnings.warn( 

1706 f"IQ privileges (XEP0356) not granted for {self.user_jid}, " 

1707 "we cannot remove bookmarks for the user" 

1708 ) 

1709 except IqError as e: 

1710 warnings.warn( 

1711 f"Something went wrong while trying to remove a bookmark: {e}" 

1712 ) 

1713 

1714 async def on_avatar(self, data: bytes | None, mime: str | None) -> str | None: 

1715 """ 

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

1717 client. 

1718 

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

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

1721 updated on the XMPP side. 

1722 

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

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

1725 room update event. 

1726 

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

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

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

1730 correct. 

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

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

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

1734 """ 

1735 raise NotImplementedError 

1736 

1737 async def on_set_config( 

1738 self, 

1739 name: str | None, 

1740 description: str | None, 

1741 ) -> None: 

1742 """ 

1743 Triggered when the user requests changing the room configuration. 

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

1745 

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

1747 :attr:`LegacyMUC.description` of this instance. 

1748 

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

1750 be ``None``. 

1751 

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

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

1754 """ 

1755 raise NotImplementedError 

1756 

1757 async def on_destroy_request(self, reason: str | None) -> None: 

1758 """ 

1759 Triggered when the user requests room destruction. 

1760 

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

1762 """ 

1763 raise NotImplementedError 

1764 

1765 async def parse_mentions( 

1766 self, text: str | None 

1767 ) -> tuple[Mention[LegacyParticipantType], ...]: 

1768 if not text: 

1769 return () 

1770 with self.xmpp.store.session() as orm: 

1771 await self.__fill_participants() 

1772 orm.add(self.stored) 

1773 participants = { 

1774 p.nickname: p for p in self.stored.participants if len(p.nickname) > 1 

1775 } 

1776 

1777 if len(participants) == 0: 

1778 return () 

1779 

1780 result = [] 

1781 for match in re.finditer( 

1782 "|".join( 

1783 sorted( 

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

1785 key=lambda nick: len(nick), 

1786 reverse=True, 

1787 ) 

1788 ), 

1789 text, 

1790 ): 

1791 span = match.span() 

1792 nick = match.group() 

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

1794 continue 

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

1796 participant = self.participant_from_store(stored=participants[nick]) 

1797 result.append( 

1798 Mention(participant=participant, start=span[0], end=span[1]) 

1799 ) 

1800 return tuple(result) 

1801 

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

1803 """ 

1804 Triggered when the user requests changing the room subject. 

1805 

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

1807 instance. 

1808 

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

1810 """ 

1811 raise NotImplementedError 

1812 

1813 async def on_set_thread_subject(self, thread: str, subject: str) -> None: 

1814 """ 

1815 Triggered when the user requests changing the subject of a specific thread. 

1816 

1817 :param thread: Legacy identifier of the thread 

1818 :param subject: The new subject for this thread. 

1819 """ 

1820 raise NotImplementedError 

1821 

1822 async def on_moderate(self, legacy_msg_id: str, reason: str | None) -> None: 

1823 """ 

1824 Triggered when the user attempts to retract a message that was sent in 

1825 a MUC using :xep:`0425`. 

1826 

1827 If retraction is not possible, this should raise the appropriate 

1828 XMPPError with a human-readable message. 

1829 

1830 NB: the legacy module is responsible for calling 

1831 :func:`LegacyParticipant.moderate` when this is successful, because 

1832 slidge will acknowledge the moderation IQ, but will not send the 

1833 moderation message from the MUC automatically. 

1834 

1835 :param legacy_msg_id: The legacy ID of the message to be retracted 

1836 :param reason: Optionally, a reason for the moderation, given by the 

1837 user-moderator. 

1838 """ 

1839 raise NotImplementedError 

1840 

1841 async def on_leave(self) -> None: 

1842 """ 

1843 Triggered when the user leaves a group via the dedicated slidge command 

1844 or the :xep:`0077` ``<remove />`` mechanism. 

1845 

1846 This should be interpreted as definitely leaving the group. 

1847 """ 

1848 raise NotImplementedError 

1849 

1850 @property 

1851 def participants_filled(self) -> bool: 

1852 # We don't store anything about participants before fill_participants() 

1853 # has been called… 

1854 if type(self).fill_participants is LegacyMUC.fill_participants: 

1855 # …except if concrete MUC implementations do not override the 

1856 # default no-op implementation. 

1857 return True 

1858 try: 

1859 return self.stored.participants_filled 

1860 except DetachedInstanceError: 

1861 with self.orm(expire_on_commit=False) as orm: 

1862 orm.add(self.stored) 

1863 with orm.no_autoflush: 

1864 orm.refresh(self.stored, ["participants_filled"]) 

1865 return self.stored.participants_filled 

1866 

1867 def get_archived_messages( 

1868 self, msg_id: str | list[str] 

1869 ) -> Iterator[ArchivedMessage]: 

1870 """ 

1871 Query the slidge archive for messages sent in this group 

1872 

1873 :param msg_id: Message ID of the message in question. Can be either a legacy ID 

1874 or an XMPP ID. 

1875 :return: Iterator over messages. A single legacy ID can map to several messages, 

1876 because of multi-attachment messages. 

1877 """ 

1878 with self.xmpp.store.session() as orm: 

1879 yield from self.xmpp.store.mam.get_messages( 

1880 orm, 

1881 self.stored.id, 

1882 ids=[str(msg_id)] if isinstance(msg_id, str) else msg_id, 

1883 ) 

1884 

1885 

1886def set_origin_id(msg: Message, origin_id: str) -> None: 

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

1888 sub.attrib["id"] = origin_id 

1889 msg.xml.append(sub) 

1890 

1891 

1892def int_or_none(x: str) -> int | None: 

1893 try: 

1894 return int(x) 

1895 except ValueError: 

1896 return None 

1897 

1898 

1899def equals_zero(x: int | None) -> bool: 

1900 if x is None: 

1901 return False 

1902 else: 

1903 return x == 0 

1904 

1905 

1906def str_to_datetime_or_none(date: str | None) -> datetime | None: 

1907 if date is None: 

1908 return None 

1909 try: 

1910 return str_to_datetime(date) 

1911 except ValueError: 

1912 return None 

1913 

1914 

1915def bookmarks_form() -> Form: 

1916 form = Form() 

1917 form["type"] = "submit" 

1918 form.add_field( 

1919 "FORM_TYPE", 

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

1921 ftype="hidden", 

1922 ) 

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

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

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

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

1927 return form 

1928 

1929 

1930LegacyMUC.participant_cls = LegacyParticipant # type:ignore[misc] 

1931 

1932_BOOKMARKS_OPTIONS = bookmarks_form() 

1933_WHITESPACE_OR_PUNCTUATION = string.whitespace + "!\"'(),.:;?@_" 

1934 

1935log = logging.getLogger(__name__)