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

971 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-22 03:37 +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 = self.session.bookmarks.space_legacy_id_to_node(stored.legacy_id) 

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

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

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

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

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

700 

701 return r 

702 

703 def shutdown(self) -> None: 

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

705 for user_full_jid in self.user_full_jids(): 

706 presence = self.xmpp.make_presence( 

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

708 ) 

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

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

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

712 presence.send() 

713 

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

715 for r in self.get_user_resources(): 

716 j = JID(self.user_jid) 

717 j.resource = r 

718 yield j 

719 

720 @property 

721 def user_muc_jid(self) -> JID: 

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

723 return user_muc_jid 

724 

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

726 msg.set_from(self.user_muc_jid) 

727 if legacy_msg_id: 

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

729 else: 

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

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

732 

733 user_part = await self.get_user_participant() 

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

735 

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

737 

738 for user_full_jid in self.user_full_jids(): 

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

740 msg = copy(msg) 

741 msg.set_to(user_full_jid) 

742 

743 msg.send() 

744 

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

746 

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

748 self.__send_configuration_change((104,)) 

749 self._send_room_presence() 

750 

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

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

753 for to in tos: 

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

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

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

757 if avatar.http_metadata is not None: 

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

759 metadata.append(avatar.http_metadata) 

760 p.append(metadata) 

761 else: 

762 p["vcard_temp_update"]["photo"] = "" 

763 p.send() 

764 

765 @timeit 

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

767 user_full_jid = join_presence.get_from() 

768 requested_nickname = join_presence.get_to().resource 

769 client_resource = user_full_jid.resource 

770 

771 if client_resource in self.get_user_resources(): 

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

773 

774 if not requested_nickname or not client_resource: 

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

776 

777 self.add_user_resource(client_resource) 

778 

779 self.log.debug( 

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

781 client_resource, 

782 self.user_jid, 

783 self.legacy_id, 

784 requested_nickname, 

785 ) 

786 

787 await self.__fill_participants() 

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

789 mav_until = await self.__get_mav() 

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

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

792 self.log.debug( 

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

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

795 mav_until, 

796 ) 

797 await self.__send_mav(user_full_jid, mav_until) 

798 else: 

799 mav_until = None 

800 

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

802 

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

804 user_participant = None 

805 

806 part_pks = [] 

807 for db_participant in self.stored.participants: 

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

809 if participant.is_user: 

810 user_participant = participant 

811 continue 

812 participant.send_initial_presence(full_jid=user_full_jid) 

813 part_pks.append(db_participant.id) 

814 

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

816 orm.execute( 

817 sa.update(Participant) 

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

819 .values(presence_sent=True) 

820 ) 

821 orm.commit() 

822 

823 if user_participant is None: 

824 user_participant = await self.get_user_participant() 

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

826 orm.add(self.stored) 

827 with orm.no_autoflush: 

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

829 if not user_participant.is_user: 

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

831 user_participant.is_user = True 

832 user_participant.send_initial_presence( 

833 user_full_jid, 

834 presence_id=join_presence["id"], 

835 nick_change=self.user_nick != requested_nickname, 

836 mav_until=mav_until, 

837 ) 

838 

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

840 

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

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

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

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

845 try: 

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

847 except ValueError: 

848 since = None 

849 if seconds is not None: 

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

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

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

853 else: 

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

855 await self.__fill_history() 

856 await self.__old_school_history( 

857 user_full_jid, 

858 maxchars=maxchars, 

859 maxstanzas=maxstanzas, 

860 since=since, 

861 ) 

862 if self.HAS_SUBJECT: 

863 subject = self.subject or "" 

864 else: 

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

866 self.__get_subject_setter_participant().set_room_subject( 

867 subject, 

868 user_full_jid, 

869 self.subject_date, 

870 ) 

871 if t := self._set_avatar_task: 

872 await t 

873 self._send_room_presence(user_full_jid) 

874 

875 async def __get_mav(self) -> str: 

876 data = self.__get_mav_data() 

877 return self.__compute_mav_ver(data) 

878 

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

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

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

882 for part in self.stored.participants: 

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

884 continue 

885 if part.affiliation == "none": 

886 continue 

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

888 return data 

889 

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

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

892 

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

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

895 affs = [] 

896 for id_, aff in data: 

897 if aff == "none": 

898 continue 

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

900 affs.sort() 

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

902 

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

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

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

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

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

908 for part in self.stored.participants: 

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

910 continue 

911 item = MUCUserItem() 

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

913 item["affiliation"] = part.affiliation 

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

915 msg.send() 

916 

917 async def get_user_participant( 

918 self, 

919 *, 

920 fill_first: bool = False, 

921 store: bool = True, 

922 occupant_id: str | None = None, 

923 ) -> "LegacyParticipantType": 

924 """ 

925 Get the participant representing the gateway user 

926 

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

928 construction (optional) 

929 :return: 

930 """ 

931 p = await self.get_participant( 

932 self.user_nick, 

933 is_user=True, 

934 fill_first=fill_first, 

935 store=store, 

936 occupant_id=occupant_id, 

937 ) 

938 self.__store_participant(p) 

939 return p 

940 

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

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

943 return 

944 try: 

945 p.commit() 

946 except IntegrityError as e: 

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

948 

949 @overload 

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

951 

952 @overload 

953 async def get_participant( 

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

955 ) -> "LegacyParticipantType": ... 

956 

957 @overload 

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

959 

960 @overload 

961 async def get_participant( 

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

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

964 

965 @overload 

966 async def get_participant( 

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

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

969 

970 @overload 

971 async def get_participant( 

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

973 ) -> "LegacyParticipantType": ... 

974 

975 @overload 

976 async def get_participant( 

977 self, nickname: str, *, occupant_id: str 

978 ) -> "LegacyParticipantType": ... 

979 

980 @overload 

981 async def get_participant( 

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

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

984 

985 @overload 

986 async def get_participant( 

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

988 ) -> "LegacyParticipantType": ... 

989 

990 @overload 

991 async def get_participant( 

992 self, 

993 nickname: str, 

994 *, 

995 create: Literal[True], 

996 is_user: bool, 

997 fill_first: bool, 

998 store: bool, 

999 ) -> "LegacyParticipantType": ... 

1000 

1001 @overload 

1002 async def get_participant( 

1003 self, 

1004 nickname: str, 

1005 *, 

1006 create: Literal[False], 

1007 is_user: bool, 

1008 fill_first: bool, 

1009 store: bool, 

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

1011 

1012 @overload 

1013 async def get_participant( 

1014 self, 

1015 nickname: str, 

1016 *, 

1017 create: bool, 

1018 fill_first: bool, 

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

1020 

1021 @overload 

1022 async def get_participant( 

1023 self, 

1024 nickname: str, 

1025 *, 

1026 is_user: Literal[True], 

1027 fill_first: bool, 

1028 store: bool, 

1029 occupant_id: str | None = None, 

1030 ) -> "LegacyParticipantType": ... 

1031 

1032 async def get_participant( 

1033 self, 

1034 nickname: str | None = None, 

1035 *, 

1036 create: bool = True, 

1037 is_user: bool = False, 

1038 fill_first: bool = False, 

1039 store: bool = True, 

1040 occupant_id: str | None = None, 

1041 ) -> "LegacyParticipantType | None": 

1042 """ 

1043 Get a participant by their nickname. 

1044 

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

1046 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

1047 

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

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

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

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

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

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

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

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

1056 xep:`0421` 

1057 :return: A participant of this room. 

1058 """ 

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

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

1061 if fill_first: 

1062 await self.__fill_participants() 

1063 if self.stored.id is not None: 

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

1065 if occupant_id is not None: 

1066 stored = ( 

1067 orm.query(Participant) 

1068 .filter( 

1069 Participant.room == self.stored, 

1070 Participant.occupant_id == occupant_id, 

1071 ) 

1072 .one_or_none() 

1073 ) 

1074 elif nickname is not None: 

1075 stored = ( 

1076 orm.query(Participant) 

1077 .filter( 

1078 Participant.room == self.stored, 

1079 (Participant.nickname == nickname) 

1080 | (Participant.resource == nickname), 

1081 ) 

1082 .one_or_none() 

1083 ) 

1084 else: 

1085 raise RuntimeError("NEVER") 

1086 if stored is not None: 

1087 if occupant_id and occupant_id != stored.occupant_id: 

1088 warnings.warn( 

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

1090 ) 

1091 part = self.participant_from_store(stored) 

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

1093 stored.nickname = nickname 

1094 orm.add(stored) 

1095 orm.commit() 

1096 return part 

1097 

1098 if not create: 

1099 return None 

1100 

1101 if occupant_id is None: 

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

1103 

1104 if nickname is None: 

1105 nickname = occupant_id 

1106 

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

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

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

1110 if is_user: 

1111 self.user_nick = nickname 

1112 

1113 p = self.participant_cls( 

1114 self, 

1115 Participant( 

1116 room=self.stored, 

1117 nickname=nickname or occupant_id, 

1118 is_user=is_user, 

1119 occupant_id=occupant_id, 

1120 ), 

1121 ) 

1122 if store: 

1123 self.__store_participant(p) 

1124 self.send_affiliation_change(p) 

1125 return p 

1126 

1127 def send_affiliation_change( 

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

1129 ) -> None: 

1130 # internal use by slidge 

1131 if not self.participants_filled: 

1132 return 

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

1134 return 

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

1136 return 

1137 if part.contact is None: 

1138 return 

1139 if part.is_system: 

1140 return 

1141 if was == part.affiliation: 

1142 return 

1143 system_part = self.get_system_participant() 

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

1145 until = self.__get_mav_data() 

1146 since = [ 

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

1148 for id_, aff in until 

1149 ] 

1150 if part.affiliation == "none": 

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

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

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

1154 item = MUCUserItem() 

1155 item["affiliation"] = part.affiliation 

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

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

1158 system_part._send(msg) 

1159 

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

1161 """ 

1162 Get a pseudo-participant, representing the room itself 

1163 

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

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

1166 service 

1167 :return: 

1168 """ 

1169 return self.participant_cls( 

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

1171 ) 

1172 

1173 @overload 

1174 async def get_participant_by_contact( 

1175 self, c: "LegacyContact" 

1176 ) -> "LegacyParticipantType": ... 

1177 

1178 @overload 

1179 async def get_participant_by_contact( 

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

1181 ) -> "LegacyParticipantType": ... 

1182 

1183 @overload 

1184 async def get_participant_by_contact( 

1185 self, 

1186 c: "LegacyContact", 

1187 *, 

1188 create: Literal[False], 

1189 occupant_id: str | None, 

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

1191 

1192 @overload 

1193 async def get_participant_by_contact( 

1194 self, 

1195 c: "LegacyContact", 

1196 *, 

1197 create: Literal[True], 

1198 occupant_id: str | None, 

1199 ) -> "LegacyParticipantType": ... 

1200 

1201 async def get_participant_by_contact( 

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

1203 ) -> "LegacyParticipantType | None": 

1204 """ 

1205 Get a non-anonymous participant. 

1206 

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

1208 that the Contact jid is associated to this participant 

1209 

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

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

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

1213 this participant. 

1214 :return: 

1215 """ 

1216 await self.session.contacts.ready 

1217 

1218 if self.stored.id is not None: 

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

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

1221 stored = ( 

1222 orm.query(Participant) 

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

1224 .one_or_none() 

1225 ) 

1226 if stored is None: 

1227 if occupant_id is not None: 

1228 stored = ( 

1229 orm.query(Participant) 

1230 .filter_by( 

1231 occupant_id=occupant_id, 

1232 room=self.stored, 

1233 contact_id=None, 

1234 ) 

1235 .one_or_none() 

1236 ) 

1237 if stored is not None: 

1238 self.log.debug( 

1239 "Updating the contact of a previously anonymous participant" 

1240 ) 

1241 stored.contact_id = c.stored.id 

1242 orm.add(stored) 

1243 orm.commit() 

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

1245 if not create: 

1246 return None 

1247 else: 

1248 if occupant_id and stored.occupant_id != occupant_id: 

1249 warnings.warn( 

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

1251 ) 

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

1253 

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

1255 

1256 if self.stored.id is None: 

1257 nick_available = True 

1258 else: 

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

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

1261 orm, self.stored.id, nickname 

1262 ) 

1263 

1264 if not nick_available: 

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

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

1267 p = self.participant_cls( 

1268 self, 

1269 Participant( 

1270 nickname=nickname, 

1271 room=self.stored, 

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

1273 ), 

1274 contact=c, 

1275 ) 

1276 

1277 self.__store_participant(p) 

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

1279 # during participants fill and history backfill we do not 

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

1281 # and role afterwards. 

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

1283 if ( 

1284 self.participants_filled 

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

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

1287 ): 

1288 self.send_affiliation_change(p) 

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

1290 return p 

1291 

1292 @overload 

1293 async def get_participant_by_legacy_id( 

1294 self, legacy_id: str 

1295 ) -> "LegacyParticipantType": ... 

1296 

1297 @overload 

1298 async def get_participant_by_legacy_id( 

1299 self, 

1300 legacy_id: str, 

1301 *, 

1302 occupant_id: str | None, 

1303 create: Literal[True], 

1304 ) -> "LegacyParticipantType": ... 

1305 

1306 @overload 

1307 async def get_participant_by_legacy_id( 

1308 self, 

1309 legacy_id: str, 

1310 *, 

1311 occupant_id: str | None, 

1312 ) -> "LegacyParticipantType": ... 

1313 

1314 @overload 

1315 async def get_participant_by_legacy_id( 

1316 self, 

1317 legacy_id: str, 

1318 *, 

1319 occupant_id: str | None, 

1320 create: Literal[False], 

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

1322 

1323 async def get_participant_by_legacy_id( 

1324 self, 

1325 legacy_id: str, 

1326 *, 

1327 occupant_id: str | None = None, 

1328 create: bool = True, 

1329 ) -> "LegacyParticipantType": 

1330 try: 

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

1332 except ContactIsUser: 

1333 return await self.get_user_participant(occupant_id=occupant_id) 

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

1335 c, create=create, occupant_id=occupant_id 

1336 ) 

1337 

1338 def remove_participant( 

1339 self, 

1340 p: "LegacyParticipantType", 

1341 kick: bool = False, 

1342 ban: bool = False, 

1343 reason: str | None = None, 

1344 ) -> None: 

1345 """ 

1346 Call this when a participant leaves the room 

1347 

1348 :param p: The participant 

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

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

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

1352 """ 

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

1354 if kick and ban: 

1355 raise TypeError("Either kick or ban") 

1356 if kick: 

1357 codes = {307} 

1358 elif ban: 

1359 codes = {301} 

1360 else: 

1361 codes = None 

1362 was = p.stored.affiliation 

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

1364 p.stored.role = "none" 

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

1366 with self.orm() as orm: 

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

1368 orm.commit() 

1369 self.send_affiliation_change(p, was) 

1370 if reason: 

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

1372 p._send(presence, force=True) 

1373 

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

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

1376 stored = ( 

1377 orm.query(Participant) 

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

1379 .one_or_none() 

1380 ) 

1381 if stored is None: 

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

1383 return 

1384 p = self.participant_from_store(stored) 

1385 if p.nickname == old_nickname: 

1386 p.nickname = new_nickname 

1387 

1388 async def __old_school_history( 

1389 self, 

1390 full_jid: JID, 

1391 maxchars: int | None = None, 

1392 maxstanzas: int | None = None, 

1393 seconds: int | None = None, 

1394 since: datetime | None = None, 

1395 ) -> None: 

1396 """ 

1397 Old-style history join (internal slidge use) 

1398 

1399 :param full_jid: 

1400 :param maxchars: 

1401 :param maxstanzas: 

1402 :param seconds: 

1403 :param since: 

1404 :return: 

1405 """ 

1406 if since is None: 

1407 if seconds is None: 

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

1409 else: 

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

1411 else: 

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

1413 

1414 for h_msg in self.archive.get_all( 

1415 start_date=start_date, end_date=None, last_page_n=maxstanzas 

1416 ): 

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

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

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

1420 msg.set_to(full_jid) 

1421 self.xmpp.send(msg, False) 

1422 

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

1424 await self.__fill_history() 

1425 

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

1427 

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

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

1430 

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

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

1433 

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

1435 

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

1437 try: 

1438 max_results = int(max_str) 

1439 except ValueError: 

1440 max_results = None 

1441 else: 

1442 max_results = None 

1443 

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

1445 after_id = after_id_rsm or after_id 

1446 

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

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

1449 last_page_n = max_results 

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

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

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

1453 if before_rsm is not True: 

1454 before_id = before_rsm 

1455 else: 

1456 last_page_n = None 

1457 

1458 first = None 

1459 last = None 

1460 count = 0 

1461 

1462 sender = form_values.get("with") 

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

1464 

1465 it = self.archive.get_all( 

1466 start_date, 

1467 end_date, 

1468 before_id, 

1469 after_id, 

1470 ids, 

1471 last_page_n, 

1472 sender_resource, 

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

1474 ) 

1475 

1476 for history_msg in it: 

1477 last = xmpp_id = history_msg.stanza_id 

1478 if first is None: 

1479 first = xmpp_id 

1480 

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

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

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

1484 wrapper_msg["mam_result"].append( 

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

1486 ) 

1487 

1488 wrapper_msg.send() 

1489 count += 1 

1490 

1491 if max_results and count == max_results: 

1492 break 

1493 

1494 if max_results: 

1495 try: 

1496 next(it) 

1497 except StopIteration: 

1498 complete = True 

1499 else: 

1500 complete = False 

1501 else: 

1502 complete = True 

1503 

1504 reply = iq.reply() 

1505 if not self.STABLE_ARCHIVE: 

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

1507 if complete: 

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

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

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

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

1512 reply.send() 

1513 

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

1515 await self.__fill_history() 

1516 await self.archive.send_metadata(iq) 

1517 

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

1519 """ 

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

1521 

1522 :param r: The resource to kick 

1523 """ 

1524 pto = JID(self.user_jid) 

1525 pto.resource = r 

1526 p = self.xmpp.make_presence( 

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

1528 ) 

1529 p["type"] = "unavailable" 

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

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

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

1533 p.send() 

1534 

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

1536 item = Item() 

1537 item["id"] = self.jid 

1538 

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

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

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

1542 

1543 try: 

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

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

1546 return None 

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

1548 # (slixmpp annoying magic) 

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

1550 item["id"] = self.jid 

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

1552 except IqTimeout: 

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

1554 return None 

1555 except IqError as exc: 

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

1557 return None 

1558 except PermissionError: 

1559 warnings.warn( 

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

1561 ) 

1562 return None 

1563 

1564 async def add_to_bookmarks( 

1565 self, 

1566 auto_join: bool = True, 

1567 preserve: bool = True, 

1568 pin: bool | None = None, 

1569 notify: WhenLiteral | None = None, 

1570 ) -> None: 

1571 """ 

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

1573 

1574 This requires that slidge has the IQ privileged set correctly 

1575 on the XMPP server 

1576 

1577 :param auto_join: whether XMPP clients should automatically join 

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

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

1580 join if they are online. 

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

1582 set by the user outside slidge 

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

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

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

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

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

1588 """ 

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

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

1591 

1592 new = Item() 

1593 new["id"] = self.jid 

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

1595 

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

1597 "always_invite_when_adding_bookmarks", True 

1598 ) 

1599 if existing is None: 

1600 change = True 

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

1602 else: 

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

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

1605 invite = False 

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

1607 

1608 existing_extensions = ( 

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

1610 ) 

1611 

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

1613 if existing_extensions: 

1614 assert existing is not None 

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

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

1617 continue 

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

1619 continue 

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

1621 

1622 if pin is not None: 

1623 if existing_extensions: 

1624 assert existing is not None 

1625 existing_pin = ( 

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

1627 "pinned", check=True 

1628 ) 

1629 is not None 

1630 ) 

1631 if existing_pin != pin: 

1632 change = True 

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

1634 

1635 if notify is not None: 

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

1637 if existing_extensions: 

1638 assert existing is not None 

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

1640 "notify", check=True 

1641 ) 

1642 if existing_notify is None: 

1643 change = True 

1644 else: 

1645 if existing_notify.get_config() != notify: 

1646 change = True 

1647 for el in existing_notify: 

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

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

1650 

1651 if change: 

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

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

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

1655 

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

1657 

1658 update_success = False 

1659 try: 

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

1661 except PermissionError: 

1662 warnings.warn( 

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

1664 ) 

1665 except IqError as e: 

1666 warnings.warn( 

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

1668 ) 

1669 else: 

1670 update_success = True 

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

1672 self.session.send_gateway_invite( 

1673 self, 

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

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

1676 "Contact your administrator.", 

1677 ) 

1678 return 

1679 else: 

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

1681 

1682 if invite: 

1683 self.session.send_gateway_invite( 

1684 self.jid, 

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

1686 ) 

1687 

1688 async def remove_from_bookmarks(self) -> None: 

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

1690 

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

1692 side-effect. 

1693 

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

1695 """ 

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

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

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

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

1700 try: 

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

1702 except PermissionError: 

1703 warnings.warn( 

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

1705 "we cannot remove bookmarks for the user" 

1706 ) 

1707 except IqError as e: 

1708 warnings.warn( 

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

1710 ) 

1711 

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

1713 """ 

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

1715 client. 

1716 

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

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

1719 updated on the XMPP side. 

1720 

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

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

1723 room update event. 

1724 

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

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

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

1728 correct. 

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

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

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

1732 """ 

1733 raise NotImplementedError 

1734 

1735 async def on_set_config( 

1736 self, 

1737 name: str | None, 

1738 description: str | None, 

1739 ) -> None: 

1740 """ 

1741 Triggered when the user requests changing the room configuration. 

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

1743 

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

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

1746 

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

1748 be ``None``. 

1749 

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

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

1752 """ 

1753 raise NotImplementedError 

1754 

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

1756 """ 

1757 Triggered when the user requests room destruction. 

1758 

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

1760 """ 

1761 raise NotImplementedError 

1762 

1763 async def parse_mentions( 

1764 self, text: str | None 

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

1766 if not text: 

1767 return () 

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

1769 await self.__fill_participants() 

1770 orm.add(self.stored) 

1771 participants = { 

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

1773 } 

1774 

1775 if len(participants) == 0: 

1776 return () 

1777 

1778 result = [] 

1779 for match in re.finditer( 

1780 "|".join( 

1781 sorted( 

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

1783 key=lambda nick: len(nick), 

1784 reverse=True, 

1785 ) 

1786 ), 

1787 text, 

1788 ): 

1789 span = match.span() 

1790 nick = match.group() 

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

1792 continue 

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

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

1795 result.append( 

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

1797 ) 

1798 return tuple(result) 

1799 

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

1801 """ 

1802 Triggered when the user requests changing the room subject. 

1803 

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

1805 instance. 

1806 

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

1808 """ 

1809 raise NotImplementedError 

1810 

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

1812 """ 

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

1814 

1815 :param thread: Legacy identifier of the thread 

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

1817 """ 

1818 raise NotImplementedError 

1819 

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

1821 """ 

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

1823 a MUC using :xep:`0425`. 

1824 

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

1826 XMPPError with a human-readable message. 

1827 

1828 NB: the legacy module is responsible for calling 

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

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

1831 moderation message from the MUC automatically. 

1832 

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

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

1835 user-moderator. 

1836 """ 

1837 raise NotImplementedError 

1838 

1839 async def on_leave(self) -> None: 

1840 """ 

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

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

1843 

1844 This should be interpreted as definitely leaving the group. 

1845 """ 

1846 raise NotImplementedError 

1847 

1848 @property 

1849 def participants_filled(self) -> bool: 

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

1851 # has been called… 

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

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

1854 # default no-op implementation. 

1855 return True 

1856 try: 

1857 return self.stored.participants_filled 

1858 except DetachedInstanceError: 

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

1860 orm.add(self.stored) 

1861 with orm.no_autoflush: 

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

1863 return self.stored.participants_filled 

1864 

1865 def get_archived_messages( 

1866 self, msg_id: str | list[str] 

1867 ) -> Iterator[ArchivedMessage]: 

1868 """ 

1869 Query the slidge archive for messages sent in this group 

1870 

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

1872 or an XMPP ID. 

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

1874 because of multi-attachment messages. 

1875 """ 

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

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

1878 orm, 

1879 self.stored.id, 

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

1881 ) 

1882 

1883 

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

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

1886 sub.attrib["id"] = origin_id 

1887 msg.xml.append(sub) 

1888 

1889 

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

1891 try: 

1892 return int(x) 

1893 except ValueError: 

1894 return None 

1895 

1896 

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

1898 if x is None: 

1899 return False 

1900 else: 

1901 return x == 0 

1902 

1903 

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

1905 if date is None: 

1906 return None 

1907 try: 

1908 return str_to_datetime(date) 

1909 except ValueError: 

1910 return None 

1911 

1912 

1913def bookmarks_form() -> Form: 

1914 form = Form() 

1915 form["type"] = "submit" 

1916 form.add_field( 

1917 "FORM_TYPE", 

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

1919 ftype="hidden", 

1920 ) 

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

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

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

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

1925 return form 

1926 

1927 

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

1929 

1930_BOOKMARKS_OPTIONS = bookmarks_form() 

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

1932 

1933log = logging.getLogger(__name__)