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

921 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-18 04:30 +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.exc import DetachedInstanceError 

29 

30from ..contact.contact import LegacyContact 

31from ..contact.roster import ContactIsUser 

32from ..core.mixins.avatar import AvatarMixin 

33from ..core.mixins.base import SessionBound 

34from ..core.mixins.disco import ChatterDiscoMixin 

35from ..core.mixins.recipient import RecipientMixin 

36from ..db.models import Participant, Room 

37from ..util.archive_msg import HistoryMessage 

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, stored: Participant, contact: LegacyContact | None = None 

194 ) -> LegacyParticipantType: 

195 if contact is None and stored.contact is not None: 

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

197 return self.participant_cls(self, stored=stored, contact=contact) 

198 

199 @property 

200 def jid(self) -> JID: 

201 return self.stored.jid 

202 

203 @jid.setter 

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

205 # FIXME: without this, mypy yields 

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

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

208 raise RuntimeError 

209 

210 @property 

211 def legacy_id(self) -> str: 

212 return self.stored.legacy_id 

213 

214 @property 

215 def space_legacy_id(self) -> str: 

216 return self.stored.space.legacy_id 

217 

218 @space_legacy_id.setter 

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

220 if ( 

221 self.stored 

222 and self.stored.space 

223 and self.stored.space.legacy_id == legacy_id 

224 ): 

225 return 

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

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

228 self.stored.space = space 

229 if self._updating_info: 

230 with orm.no_autoflush: 

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

232 return 

233 orm.add(self.stored) 

234 orm.commit() 

235 

236 def orm( 

237 self, 

238 **kwargs: Any, # noqa:ANN401 

239 ) -> OrmSession: 

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

241 

242 @property 

243 def type(self) -> MucType: 

244 return self.stored.muc_type 

245 

246 @type.setter 

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

248 if self.type == type_: 

249 return 

250 self.update_stored_attribute(muc_type=type_) 

251 

252 @property 

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

254 return self.stored.n_participants 

255 

256 @n_participants.setter 

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

258 if self.stored.n_participants == n_participants: 

259 return 

260 self.update_stored_attribute(n_participants=n_participants) 

261 

262 def _set_logger(self) -> None: 

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

264 

265 def __repr__(self) -> str: 

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

267 

268 @property 

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

270 if self.stored.subject_date is None: 

271 return None 

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

273 

274 @subject_date.setter 

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

276 if self.subject_date == when: 

277 return 

278 self.update_stored_attribute(subject_date=when) 

279 

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

281 part = self.get_system_participant() 

282 part.send_configuration_change(codes) 

283 

284 @property 

285 def user_nick(self) -> str: 

286 return ( 

287 self.stored.user_nick 

288 or self.session.bookmarks.user_nick 

289 or self.user_jid.node 

290 ) 

291 

292 @user_nick.setter 

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

294 if nick == self.user_nick: 

295 return 

296 self.update_stored_attribute(user_nick=nick) 

297 

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

299 stored_set = self.get_user_resources() 

300 if resource in stored_set: 

301 return 

302 stored_set.add(resource) 

303 self.update_stored_attribute( 

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

305 ) 

306 

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

308 stored_str = self.stored.user_resources 

309 if stored_str is None: 

310 return set() 

311 return set(json.loads(stored_str)) 

312 

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

314 stored_set = self.get_user_resources() 

315 if resource not in stored_set: 

316 return 

317 stored_set.remove(resource) 

318 self.update_stored_attribute( 

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

320 ) 

321 

322 @asynccontextmanager 

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

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

325 yield 

326 

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

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

329 

330 async def __fill_participants(self) -> None: 

331 if self.participants_filled: 

332 return 

333 

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

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

336 orm.add(self.stored) 

337 with orm.no_autoflush: 

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

339 if self.participants_filled: 

340 return 

341 parts: list[Participant] = [] 

342 resources = set[str]() 

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

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

345 user_found = False 

346 async for participant in self.fill_participants(): 

347 if participant.is_user: 

348 user_found = True 

349 if participant.stored.resource in resources: 

350 self.log.debug( 

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

352 participant.stored.resource, 

353 ) 

354 continue 

355 parts.append(participant.stored) 

356 resources.add(participant.stored.resource) 

357 

358 if not user_found: 

359 participant = await self.get_user_participant() 

360 if participant.stored.resource in resources: 

361 for p in parts: 

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

363 p.is_user = True 

364 else: 

365 parts.append(participant.stored) 

366 resources.add(participant.stored.resource) 

367 

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

369 orm.add(self.stored) 

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

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

372 # and the participant_filled attribute. 

373 with orm.no_autoflush: 

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

375 for part in parts: 

376 orm.merge(part) 

377 self.stored.participants_filled = True 

378 orm.commit() 

379 

380 async def get_participants( 

381 self, affiliation: MucAffiliation | None = None 

382 ) -> AsyncIterator[LegacyParticipantType]: 

383 await self.__fill_participants() 

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

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

386 db_participants = self.stored.participants 

387 for db_participant in db_participants: 

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

389 continue 

390 yield self.participant_from_store(db_participant) 

391 

392 async def __fill_history(self) -> None: 

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

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

395 orm.add(self.stored) 

396 with orm.no_autoflush: 

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

398 if self.stored.history_filled: 

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

400 return 

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

402 try: 

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

404 if before is not None: 

405 before = before._replace(id=before.id) 

406 if after is not None: 

407 after = after._replace(id=after.id) 

408 await self.backfill(before, after) 

409 except NotImplementedError: 

410 return 

411 except Exception as e: 

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

413 

414 self.stored.history_filled = True 

415 self.commit() 

416 

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

418 return self.name 

419 

420 @property 

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

422 return self.stored.name 

423 

424 @name.setter 

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

426 if self.name == n: 

427 return 

428 self.update_stored_attribute(name=n) 

429 self._set_logger() 

430 self.__send_configuration_change((104,)) 

431 

432 @property 

433 def description(self) -> str: 

434 return self.stored.description or "" 

435 

436 @description.setter 

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

438 if self.description == d: 

439 return 

440 self.update_stored_attribute(description=d) 

441 self.__send_configuration_change((104,)) 

442 

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

444 pto = p.get_to() 

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

446 return 

447 

448 pfrom = p.get_from() 

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

450 return 

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

452 if pto.resource != self.user_nick: 

453 self.log.debug( 

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

455 ) 

456 self.remove_user_resource(resource) 

457 else: 

458 self.log.debug( 

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

460 ) 

461 

462 async def update_info(self) -> None: 

463 """ 

464 Fetch information about this group from the legacy network 

465 

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

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

468 of participants etc. 

469 

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

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

472 is no change, you should not call 

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

474 attempt to modify 

475 the :attr:.avatar property. 

476 """ 

477 raise NotImplementedError 

478 

479 async def backfill( 

480 self, 

481 after: HoleBound | None = None, 

482 before: HoleBound | None = None, 

483 ) -> None: 

484 """ 

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

486 

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

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

489 run for a given group. 

490 

491 :param after: Fetch messages after this one. 

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

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

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

495 the user registered. 

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

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

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

499 :param before: Fetch messages before this one. 

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

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

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

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

504 """ 

505 raise NotImplementedError 

506 

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

508 """ 

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

510 

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

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

513 before yielding them. 

514 """ 

515 return 

516 yield 

517 

518 @property 

519 def subject(self) -> str: 

520 return self.stored.subject or "" 

521 

522 @subject.setter 

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

524 if s == self.subject: 

525 return 

526 

527 self.update_stored_attribute(subject=s) 

528 self.__get_subject_setter_participant().set_room_subject( 

529 s, None, self.subject_date, False 

530 ) 

531 

532 @property 

533 def is_anonymous(self) -> bool: 

534 return self.type == MucType.CHANNEL 

535 

536 @property 

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

538 return self.stored.subject_setter 

539 

540 @subject_setter.setter 

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

542 if isinstance(subject_setter, LegacyContact): 

543 subject_setter = subject_setter.name 

544 elif isinstance(subject_setter, LegacyParticipant): 

545 subject_setter = subject_setter.nickname 

546 

547 if subject_setter == self.subject_setter: 

548 return 

549 assert isinstance(subject_setter, str | None) 

550 self.update_stored_attribute(subject_setter=subject_setter) 

551 

552 def __get_subject_setter_participant(self) -> AnyParticipant: 

553 if self.subject_setter is None: 

554 return self.get_system_participant() 

555 return self.participant_cls( 

556 self, 

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

558 ) 

559 

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

561 features = [ 

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

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

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

565 "urn:xmpp:mam:2", 

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

567 "urn:xmpp:sid:0", 

568 "muc_persistent", 

569 "vcard-temp", 

570 "urn:xmpp:ping", 

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

572 "jabber:iq:register", 

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

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

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

576 ] 

577 if self.type == MucType.GROUP: 

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

579 elif self.type == MucType.CHANNEL: 

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

581 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

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

583 

584 try: # oh boy, this sucks 

585 has_space = self.stored.space is not None 

586 except DetachedInstanceError: 

587 self.refresh() 

588 has_space = self.stored.space is not None 

589 

590 if has_space: 

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

592 return features 

593 

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

595 is_group = self.type == MucType.GROUP 

596 

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

598 

599 form.add_field( 

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

601 ) 

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

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

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

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

606 

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

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

609 n = orm.scalar( 

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

611 room_id=self.stored.id 

612 ) 

613 ) 

614 else: 

615 n = self.n_participants 

616 if n is not None: 

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

618 

619 if d := self.description: 

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

621 

622 if s := self.subject: 

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

624 

625 if name := self.name: 

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

627 

628 if self._set_avatar_task is not None: 

629 await self._set_avatar_task 

630 avatar = self.get_avatar() 

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

632 form.add_field( 

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

634 ) 

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

636 

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

638 form.add_field( 

639 "muc#roomconfig_whois", 

640 "list-single", 

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

642 ) 

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

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

645 

646 r = [form] 

647 

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

649 r.append(reaction_form) 

650 

651 if self.stored.space is not None: 

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

653 self.stored.space.legacy_id 

654 ) 

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

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

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

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

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

660 

661 return r 

662 

663 def shutdown(self) -> None: 

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

665 for user_full_jid in self.user_full_jids(): 

666 presence = self.xmpp.make_presence( 

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

668 ) 

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

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

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

672 presence.send() 

673 

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

675 for r in self.get_user_resources(): 

676 j = JID(self.user_jid) 

677 j.resource = r 

678 yield j 

679 

680 @property 

681 def user_muc_jid(self) -> JID: 

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

683 return user_muc_jid 

684 

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

686 msg.set_from(self.user_muc_jid) 

687 if legacy_msg_id: 

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

689 else: 

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

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

692 

693 user_part = await self.get_user_participant() 

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

695 

696 self.archive.add(msg, user_part) 

697 

698 for user_full_jid in self.user_full_jids(): 

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

700 msg = copy(msg) 

701 msg.set_to(user_full_jid) 

702 

703 msg.send() 

704 

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

706 

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

708 self.__send_configuration_change((104,)) 

709 self._send_room_presence() 

710 

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

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

713 for to in tos: 

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

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

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

717 else: 

718 p["vcard_temp_update"]["photo"] = "" 

719 p.send() 

720 

721 @timeit 

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

723 user_full_jid = join_presence.get_from() 

724 requested_nickname = join_presence.get_to().resource 

725 client_resource = user_full_jid.resource 

726 

727 if client_resource in self.get_user_resources(): 

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

729 

730 if not requested_nickname or not client_resource: 

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

732 

733 self.add_user_resource(client_resource) 

734 

735 self.log.debug( 

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

737 client_resource, 

738 self.user_jid, 

739 self.legacy_id, 

740 requested_nickname, 

741 ) 

742 

743 user_nick = self.user_nick 

744 user_participant = None 

745 await self.__fill_participants() 

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

747 mav_until = await self.__get_mav() 

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

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

750 self.log.debug( 

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

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

753 mav_until, 

754 ) 

755 await self.__send_mav(user_full_jid, mav_until) 

756 else: 

757 mav_until = None 

758 async for participant in self.get_participants(): 

759 if participant.is_user: 

760 user_participant = participant 

761 continue 

762 participant.send_initial_presence(full_jid=user_full_jid) 

763 

764 if user_participant is None: 

765 user_participant = await self.get_user_participant() 

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

767 orm.add(self.stored) 

768 with orm.no_autoflush: 

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

770 if not user_participant.is_user: 

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

772 user_participant.is_user = True 

773 user_participant.send_initial_presence( 

774 user_full_jid, 

775 presence_id=join_presence["id"], 

776 nick_change=user_nick != requested_nickname, 

777 mav_until=mav_until, 

778 ) 

779 

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

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

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

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

784 try: 

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

786 except ValueError: 

787 since = None 

788 if seconds is not None: 

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

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

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

792 else: 

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

794 await self.__fill_history() 

795 await self.__old_school_history( 

796 user_full_jid, 

797 maxchars=maxchars, 

798 maxstanzas=maxstanzas, 

799 since=since, 

800 ) 

801 if self.HAS_SUBJECT: 

802 subject = self.subject or "" 

803 else: 

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

805 self.__get_subject_setter_participant().set_room_subject( 

806 subject, 

807 user_full_jid, 

808 self.subject_date, 

809 ) 

810 if t := self._set_avatar_task: 

811 await t 

812 self._send_room_presence(user_full_jid) 

813 

814 async def __get_mav(self) -> str: 

815 data = self.__get_mav_data() 

816 return self.__compute_mav_ver(data) 

817 

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

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

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

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

822 for part in self.stored.participants: 

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

824 continue 

825 if part.affiliation == "none": 

826 continue 

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

828 return data 

829 

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

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

832 

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

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

835 affs = [] 

836 for id_, aff in data: 

837 if aff == "none": 

838 continue 

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

840 affs.sort() 

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

842 

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

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

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

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

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

848 for part in self.stored.participants: 

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

850 continue 

851 item = MUCUserItem() 

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

853 item["affiliation"] = part.affiliation 

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

855 msg.send() 

856 

857 async def get_user_participant( 

858 self, 

859 *, 

860 fill_first: bool = False, 

861 store: bool = True, 

862 occupant_id: str | None = None, 

863 ) -> "LegacyParticipantType": 

864 """ 

865 Get the participant representing the gateway user 

866 

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

868 construction (optional) 

869 :return: 

870 """ 

871 p = await self.get_participant( 

872 self.user_nick, 

873 is_user=True, 

874 fill_first=fill_first, 

875 store=store, 

876 occupant_id=occupant_id, 

877 ) 

878 self.__store_participant(p) 

879 return p 

880 

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

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

883 return 

884 try: 

885 p.commit() 

886 except IntegrityError as e: 

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

888 self.stored = p.stored.room 

889 

890 @overload 

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

892 

893 @overload 

894 async def get_participant( 

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

896 ) -> "LegacyParticipantType": ... 

897 

898 @overload 

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

900 

901 @overload 

902 async def get_participant( 

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

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

905 

906 @overload 

907 async def get_participant( 

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

909 ) -> "LegacyParticipantType": ... 

910 

911 @overload 

912 async def get_participant( 

913 self, nickname: str, *, occupant_id: str 

914 ) -> "LegacyParticipantType": ... 

915 

916 @overload 

917 async def get_participant( 

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

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

920 

921 @overload 

922 async def get_participant( 

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

924 ) -> "LegacyParticipantType": ... 

925 

926 @overload 

927 async def get_participant( 

928 self, 

929 nickname: str, 

930 *, 

931 create: Literal[True], 

932 is_user: bool, 

933 fill_first: bool, 

934 store: bool, 

935 ) -> "LegacyParticipantType": ... 

936 

937 @overload 

938 async def get_participant( 

939 self, 

940 nickname: str, 

941 *, 

942 create: Literal[False], 

943 is_user: bool, 

944 fill_first: bool, 

945 store: bool, 

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

947 

948 @overload 

949 async def get_participant( 

950 self, 

951 nickname: str, 

952 *, 

953 create: bool, 

954 fill_first: bool, 

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

956 

957 @overload 

958 async def get_participant( 

959 self, 

960 nickname: str, 

961 *, 

962 is_user: Literal[True], 

963 fill_first: bool, 

964 store: bool, 

965 occupant_id: str | None = None, 

966 ) -> "LegacyParticipantType": ... 

967 

968 async def get_participant( 

969 self, 

970 nickname: str | None = None, 

971 *, 

972 create: bool = True, 

973 is_user: bool = False, 

974 fill_first: bool = False, 

975 store: bool = True, 

976 occupant_id: str | None = None, 

977 ) -> "LegacyParticipantType | None": 

978 """ 

979 Get a participant by their nickname. 

980 

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

982 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

983 

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

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

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

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

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

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

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

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

992 xep:`0421` 

993 :return: A participant of this room. 

994 """ 

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

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

997 if fill_first: 

998 await self.__fill_participants() 

999 if self.stored.id is not None: 

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

1001 if occupant_id is not None: 

1002 stored = ( 

1003 orm.query(Participant) 

1004 .filter( 

1005 Participant.room == self.stored, 

1006 Participant.occupant_id == occupant_id, 

1007 ) 

1008 .one_or_none() 

1009 ) 

1010 elif nickname is not None: 

1011 stored = ( 

1012 orm.query(Participant) 

1013 .filter( 

1014 Participant.room == self.stored, 

1015 (Participant.nickname == nickname) 

1016 | (Participant.resource == nickname), 

1017 ) 

1018 .one_or_none() 

1019 ) 

1020 else: 

1021 raise RuntimeError("NEVER") 

1022 if stored is not None: 

1023 if occupant_id and occupant_id != stored.occupant_id: 

1024 warnings.warn( 

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

1026 ) 

1027 part = self.participant_from_store(stored) 

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

1029 stored.nickname = nickname 

1030 orm.add(stored) 

1031 orm.commit() 

1032 return part 

1033 

1034 if not create: 

1035 return None 

1036 

1037 if occupant_id is None: 

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

1039 

1040 if nickname is None: 

1041 nickname = occupant_id 

1042 

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

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

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

1046 if is_user: 

1047 self.user_nick = nickname 

1048 

1049 p = self.participant_cls( 

1050 self, 

1051 Participant( 

1052 room=self.stored, 

1053 nickname=nickname or occupant_id, 

1054 is_user=is_user, 

1055 occupant_id=occupant_id, 

1056 ), 

1057 ) 

1058 if store: 

1059 self.__store_participant(p) 

1060 self.send_affiliation_change(p) 

1061 return p 

1062 

1063 def send_affiliation_change( 

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

1065 ) -> None: 

1066 # internal use by slidge 

1067 if not self.participants_filled: 

1068 return 

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

1070 return 

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

1072 return 

1073 if part.contact is None: 

1074 return 

1075 if part.is_system: 

1076 return 

1077 if was == part.affiliation: 

1078 return 

1079 system_part = self.get_system_participant() 

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

1081 data = self.__get_mav_data() 

1082 since_data = [ 

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

1084 for id_, aff in data 

1085 ] 

1086 if part.affiliation == "none": 

1087 since_data.append((self.__get_part_mav_id(part), was)) 

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

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

1090 item = MUCUserItem() 

1091 item["affiliation"] = part.affiliation 

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

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

1094 system_part._send(msg) 

1095 

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

1097 """ 

1098 Get a pseudo-participant, representing the room itself 

1099 

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

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

1102 service 

1103 :return: 

1104 """ 

1105 return self.participant_cls( 

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

1107 ) 

1108 

1109 @overload 

1110 async def get_participant_by_contact( 

1111 self, c: "LegacyContact" 

1112 ) -> "LegacyParticipantType": ... 

1113 

1114 @overload 

1115 async def get_participant_by_contact( 

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

1117 ) -> "LegacyParticipantType": ... 

1118 

1119 @overload 

1120 async def get_participant_by_contact( 

1121 self, 

1122 c: "LegacyContact", 

1123 *, 

1124 create: Literal[False], 

1125 occupant_id: str | None, 

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

1127 

1128 @overload 

1129 async def get_participant_by_contact( 

1130 self, 

1131 c: "LegacyContact", 

1132 *, 

1133 create: Literal[True], 

1134 occupant_id: str | None, 

1135 ) -> "LegacyParticipantType": ... 

1136 

1137 async def get_participant_by_contact( 

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

1139 ) -> "LegacyParticipantType | None": 

1140 """ 

1141 Get a non-anonymous participant. 

1142 

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

1144 that the Contact jid is associated to this participant 

1145 

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

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

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

1149 this participant. 

1150 :return: 

1151 """ 

1152 await self.session.contacts.ready 

1153 

1154 if self.stored.id is not None: 

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

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

1157 stored = ( 

1158 orm.query(Participant) 

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

1160 .one_or_none() 

1161 ) 

1162 if stored is None: 

1163 if occupant_id is not None: 

1164 stored = ( 

1165 orm.query(Participant) 

1166 .filter_by( 

1167 occupant_id=occupant_id, 

1168 room=self.stored, 

1169 contact_id=None, 

1170 ) 

1171 .one_or_none() 

1172 ) 

1173 if stored is not None: 

1174 self.log.debug( 

1175 "Updating the contact of a previously anonymous participant" 

1176 ) 

1177 stored.contact_id = c.stored.id 

1178 orm.add(stored) 

1179 orm.commit() 

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

1181 if not create: 

1182 return None 

1183 else: 

1184 if occupant_id and stored.occupant_id != occupant_id: 

1185 warnings.warn( 

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

1187 ) 

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

1189 

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

1191 

1192 if self.stored.id is None: 

1193 nick_available = True 

1194 else: 

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

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

1197 orm, self.stored.id, nickname 

1198 ) 

1199 

1200 if not nick_available: 

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

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

1203 p = self.participant_cls( 

1204 self, 

1205 Participant( 

1206 nickname=nickname, 

1207 room=self.stored, 

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

1209 ), 

1210 contact=c, 

1211 ) 

1212 

1213 self.__store_participant(p) 

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

1215 # during participants fill and history backfill we do not 

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

1217 # and role afterwards. 

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

1219 if ( 

1220 self.participants_filled 

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

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

1223 ): 

1224 self.send_affiliation_change(p) 

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

1226 return p 

1227 

1228 @overload 

1229 async def get_participant_by_legacy_id( 

1230 self, legacy_id: str 

1231 ) -> "LegacyParticipantType": ... 

1232 

1233 @overload 

1234 async def get_participant_by_legacy_id( 

1235 self, 

1236 legacy_id: str, 

1237 *, 

1238 occupant_id: str | None, 

1239 create: Literal[True], 

1240 ) -> "LegacyParticipantType": ... 

1241 

1242 @overload 

1243 async def get_participant_by_legacy_id( 

1244 self, 

1245 legacy_id: str, 

1246 *, 

1247 occupant_id: str | None, 

1248 ) -> "LegacyParticipantType": ... 

1249 

1250 @overload 

1251 async def get_participant_by_legacy_id( 

1252 self, 

1253 legacy_id: str, 

1254 *, 

1255 occupant_id: str | None, 

1256 create: Literal[False], 

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

1258 

1259 async def get_participant_by_legacy_id( 

1260 self, 

1261 legacy_id: str, 

1262 *, 

1263 occupant_id: str | None = None, 

1264 create: bool = True, 

1265 ) -> "LegacyParticipantType": 

1266 try: 

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

1268 except ContactIsUser: 

1269 return await self.get_user_participant(occupant_id=occupant_id) 

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

1271 c, create=create, occupant_id=occupant_id 

1272 ) 

1273 

1274 def remove_participant( 

1275 self, 

1276 p: "LegacyParticipantType", 

1277 kick: bool = False, 

1278 ban: bool = False, 

1279 reason: str | None = None, 

1280 ) -> None: 

1281 """ 

1282 Call this when a participant leaves the room 

1283 

1284 :param p: The participant 

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

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

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

1288 """ 

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

1290 if kick and ban: 

1291 raise TypeError("Either kick or ban") 

1292 if kick: 

1293 codes = {307} 

1294 elif ban: 

1295 codes = {301} 

1296 else: 

1297 codes = None 

1298 was = p.stored.affiliation 

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

1300 p.stored.role = "none" 

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

1302 self.send_affiliation_change(p, was) 

1303 if reason: 

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

1305 p._send(presence, force=True) 

1306 with self.orm() as orm: 

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

1308 orm.commit() 

1309 

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

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

1312 stored = ( 

1313 orm.query(Participant) 

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

1315 .one_or_none() 

1316 ) 

1317 if stored is None: 

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

1319 return 

1320 p = self.participant_from_store(stored) 

1321 if p.nickname == old_nickname: 

1322 p.nickname = new_nickname 

1323 

1324 async def __old_school_history( 

1325 self, 

1326 full_jid: JID, 

1327 maxchars: int | None = None, 

1328 maxstanzas: int | None = None, 

1329 seconds: int | None = None, 

1330 since: datetime | None = None, 

1331 ) -> None: 

1332 """ 

1333 Old-style history join (internal slidge use) 

1334 

1335 :param full_jid: 

1336 :param maxchars: 

1337 :param maxstanzas: 

1338 :param seconds: 

1339 :param since: 

1340 :return: 

1341 """ 

1342 if since is None: 

1343 if seconds is None: 

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

1345 else: 

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

1347 else: 

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

1349 

1350 for h_msg in self.archive.get_all( 

1351 start_date=start_date, end_date=None, last_page_n=maxstanzas 

1352 ): 

1353 msg = h_msg.stanza_component_ns 

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

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

1356 msg.set_to(full_jid) 

1357 self.xmpp.send(msg, False) 

1358 

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

1360 await self.__fill_history() 

1361 

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

1363 

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

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

1366 

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

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

1369 

1370 sender = form_values.get("with") 

1371 

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

1373 

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

1375 try: 

1376 max_results = int(max_str) 

1377 except ValueError: 

1378 max_results = None 

1379 else: 

1380 max_results = None 

1381 

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

1383 after_id = after_id_rsm or after_id 

1384 

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

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

1387 last_page_n = max_results 

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

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

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

1391 if before_rsm is not True: 

1392 before_id = before_rsm 

1393 else: 

1394 last_page_n = None 

1395 

1396 first = None 

1397 last = None 

1398 count = 0 

1399 

1400 it = self.archive.get_all( 

1401 start_date, 

1402 end_date, 

1403 before_id, 

1404 after_id, 

1405 ids, 

1406 last_page_n, 

1407 sender, 

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

1409 ) 

1410 

1411 for history_msg in it: 

1412 last = xmpp_id = history_msg.id 

1413 if first is None: 

1414 first = xmpp_id 

1415 

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

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

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

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

1420 

1421 wrapper_msg.send() 

1422 count += 1 

1423 

1424 if max_results and count == max_results: 

1425 break 

1426 

1427 if max_results: 

1428 try: 

1429 next(it) 

1430 except StopIteration: 

1431 complete = True 

1432 else: 

1433 complete = False 

1434 else: 

1435 complete = True 

1436 

1437 reply = iq.reply() 

1438 if not self.STABLE_ARCHIVE: 

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

1440 if complete: 

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

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

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

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

1445 reply.send() 

1446 

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

1448 await self.__fill_history() 

1449 await self.archive.send_metadata(iq) 

1450 

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

1452 """ 

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

1454 

1455 :param r: The resource to kick 

1456 """ 

1457 pto = JID(self.user_jid) 

1458 pto.resource = r 

1459 p = self.xmpp.make_presence( 

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

1461 ) 

1462 p["type"] = "unavailable" 

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

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

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

1466 p.send() 

1467 

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

1469 item = Item() 

1470 item["id"] = self.jid 

1471 

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

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

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

1475 

1476 try: 

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

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

1479 return None 

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

1481 # (slixmpp annoying magic) 

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

1483 item["id"] = self.jid 

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

1485 except IqTimeout: 

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

1487 return None 

1488 except IqError as exc: 

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

1490 return None 

1491 except PermissionError: 

1492 warnings.warn( 

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

1494 ) 

1495 return None 

1496 

1497 async def add_to_bookmarks( 

1498 self, 

1499 auto_join: bool = True, 

1500 preserve: bool = True, 

1501 pin: bool | None = None, 

1502 notify: WhenLiteral | None = None, 

1503 ) -> None: 

1504 """ 

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

1506 

1507 This requires that slidge has the IQ privileged set correctly 

1508 on the XMPP server 

1509 

1510 :param auto_join: whether XMPP clients should automatically join 

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

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

1513 join if they are online. 

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

1515 set by the user outside slidge 

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

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

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

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

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

1521 """ 

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

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

1524 

1525 new = Item() 

1526 new["id"] = self.jid 

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

1528 

1529 if existing is None: 

1530 change = True 

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

1532 else: 

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

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

1535 

1536 existing_extensions = ( 

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

1538 ) 

1539 

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

1541 if existing_extensions: 

1542 assert existing is not None 

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

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

1545 continue 

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

1547 continue 

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

1549 

1550 if pin is not None: 

1551 if existing_extensions: 

1552 assert existing is not None 

1553 existing_pin = ( 

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

1555 "pinned", check=True 

1556 ) 

1557 is not None 

1558 ) 

1559 if existing_pin != pin: 

1560 change = True 

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

1562 

1563 if notify is not None: 

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

1565 if existing_extensions: 

1566 assert existing is not None 

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

1568 "notify", check=True 

1569 ) 

1570 if existing_notify is None: 

1571 change = True 

1572 else: 

1573 if existing_notify.get_config() != notify: 

1574 change = True 

1575 for el in existing_notify: 

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

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

1578 

1579 if change: 

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

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

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

1583 

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

1585 

1586 try: 

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

1588 except PermissionError: 

1589 warnings.warn( 

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

1591 ) 

1592 # fallback by forcing invitation 

1593 bookmark_add_fail = True 

1594 except IqError as e: 

1595 warnings.warn( 

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

1597 ) 

1598 # fallback by forcing invitation 

1599 bookmark_add_fail = True 

1600 else: 

1601 bookmark_add_fail = False 

1602 else: 

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

1604 return 

1605 

1606 if bookmark_add_fail: 

1607 self.session.send_gateway_invite( 

1608 self, 

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

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

1611 "Contact your administrator.", 

1612 ) 

1613 elif existing is None and self.session.user.preferences.get( 

1614 "always_invite_when_adding_bookmarks", True 

1615 ): 

1616 self.session.send_gateway_invite( 

1617 self, 

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

1619 ) 

1620 

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

1622 """ 

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

1624 client. 

1625 

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

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

1628 updated on the XMPP side. 

1629 

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

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

1632 room update event. 

1633 

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

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

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

1637 correct. 

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

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

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

1641 """ 

1642 raise NotImplementedError 

1643 

1644 async def on_set_config( 

1645 self, 

1646 name: str | None, 

1647 description: str | None, 

1648 ) -> None: 

1649 """ 

1650 Triggered when the user requests changing the room configuration. 

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

1652 

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

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

1655 

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

1657 be ``None``. 

1658 

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

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

1661 """ 

1662 raise NotImplementedError 

1663 

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

1665 """ 

1666 Triggered when the user requests room destruction. 

1667 

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

1669 """ 

1670 raise NotImplementedError 

1671 

1672 async def parse_mentions( 

1673 self, text: str | None 

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

1675 if not text: 

1676 return () 

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

1678 await self.__fill_participants() 

1679 orm.add(self.stored) 

1680 participants = { 

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

1682 } 

1683 

1684 if len(participants) == 0: 

1685 return () 

1686 

1687 result = [] 

1688 for match in re.finditer( 

1689 "|".join( 

1690 sorted( 

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

1692 key=lambda nick: len(nick), 

1693 reverse=True, 

1694 ) 

1695 ), 

1696 text, 

1697 ): 

1698 span = match.span() 

1699 nick = match.group() 

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

1701 continue 

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

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

1704 result.append( 

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

1706 ) 

1707 return tuple(result) 

1708 

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

1710 """ 

1711 Triggered when the user requests changing the room subject. 

1712 

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

1714 instance. 

1715 

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

1717 """ 

1718 raise NotImplementedError 

1719 

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

1721 """ 

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

1723 

1724 :param thread: Legacy identifier of the thread 

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

1726 """ 

1727 raise NotImplementedError 

1728 

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

1730 """ 

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

1732 a MUC using :xep:`0425`. 

1733 

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

1735 XMPPError with a human-readable message. 

1736 

1737 NB: the legacy module is responsible for calling 

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

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

1740 moderation message from the MUC automatically. 

1741 

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

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

1744 user-moderator. 

1745 """ 

1746 raise NotImplementedError 

1747 

1748 async def on_leave(self) -> None: 

1749 """ 

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

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

1752 

1753 This should be interpreted as definitely leaving the group. 

1754 """ 

1755 raise NotImplementedError 

1756 

1757 @property 

1758 def participants_filled(self) -> bool: 

1759 try: 

1760 return self.stored.participants_filled 

1761 except DetachedInstanceError: 

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

1763 orm.add(self.stored) 

1764 with orm.no_autoflush: 

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

1766 return self.stored.participants_filled 

1767 

1768 def get_archived_messages(self, msg_id: str) -> Iterator[HistoryMessage]: 

1769 """ 

1770 Query the slidge archive for messages sent in this group 

1771 

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

1773 or an XMPP ID. 

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

1775 because of multi-attachment messages. 

1776 """ 

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

1778 for stored in self.xmpp.store.mam.get_messages( 

1779 orm, self.stored.id, ids=[str(msg_id)] 

1780 ): 

1781 yield HistoryMessage(stored.stanza) 

1782 

1783 

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

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

1786 sub.attrib["id"] = origin_id 

1787 msg.xml.append(sub) 

1788 

1789 

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

1791 try: 

1792 return int(x) 

1793 except ValueError: 

1794 return None 

1795 

1796 

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

1798 if x is None: 

1799 return False 

1800 else: 

1801 return x == 0 

1802 

1803 

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

1805 if date is None: 

1806 return None 

1807 try: 

1808 return str_to_datetime(date) 

1809 except ValueError: 

1810 return None 

1811 

1812 

1813def bookmarks_form() -> Form: 

1814 form = Form() 

1815 form["type"] = "submit" 

1816 form.add_field( 

1817 "FORM_TYPE", 

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

1819 ftype="hidden", 

1820 ) 

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

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

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

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

1825 return form 

1826 

1827 

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

1829 

1830_BOOKMARKS_OPTIONS = bookmarks_form() 

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

1832 

1833log = logging.getLogger(__name__)