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

943 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-10 04:45 +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) -> None: 

438 d = d or "" 

439 if self.description == d: 

440 return 

441 self.update_stored_attribute(description=d) 

442 self.__send_configuration_change((104,)) 

443 

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

445 pto = p.get_to() 

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

447 return 

448 

449 pfrom = p.get_from() 

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

451 return 

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

453 if pto.resource != self.user_nick: 

454 self.log.debug( 

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

456 ) 

457 self.remove_user_resource(resource) 

458 else: 

459 self.log.debug( 

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

461 ) 

462 

463 async def update_info(self) -> None: 

464 """ 

465 Fetch information about this group from the legacy network 

466 

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

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

469 of participants etc. 

470 

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

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

473 is no change, you should not call 

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

475 attempt to modify 

476 the :attr:.avatar property. 

477 """ 

478 raise NotImplementedError 

479 

480 async def backfill( 

481 self, 

482 after: HoleBound | None = None, 

483 before: HoleBound | None = None, 

484 ) -> None: 

485 """ 

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

487 

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

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

490 run for a given group. 

491 

492 :param after: Fetch messages after this one. 

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

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

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

496 the user registered. 

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

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

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

500 :param before: Fetch messages before this one. 

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

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

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

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

505 """ 

506 raise NotImplementedError 

507 

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

509 """ 

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

511 

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

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

514 before yielding them. 

515 """ 

516 return 

517 yield 

518 

519 @property 

520 def subject(self) -> str: 

521 return self.stored.subject or "" 

522 

523 @subject.setter 

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

525 if s == self.subject: 

526 return 

527 

528 self.update_stored_attribute(subject=s) 

529 self.__get_subject_setter_participant().set_room_subject( 

530 s, None, self.subject_date, False 

531 ) 

532 

533 @property 

534 def is_anonymous(self) -> bool: 

535 return self.type == MucType.CHANNEL 

536 

537 @property 

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

539 return self.stored.subject_setter 

540 

541 @subject_setter.setter 

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

543 if isinstance(subject_setter, LegacyContact): 

544 subject_setter = subject_setter.name 

545 elif isinstance(subject_setter, LegacyParticipant): 

546 subject_setter = subject_setter.nickname 

547 

548 if subject_setter == self.subject_setter: 

549 return 

550 assert isinstance(subject_setter, str | None) 

551 self.update_stored_attribute(subject_setter=subject_setter) 

552 

553 def __get_subject_setter_participant(self) -> AnyParticipant: 

554 if self.subject_setter is None: 

555 return self.get_system_participant() 

556 return self.participant_cls( 

557 self, 

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

559 ) 

560 

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

562 features = [ 

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

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

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

566 "urn:xmpp:mam:2", 

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

568 "urn:xmpp:sid:0", 

569 "muc_persistent", 

570 "vcard-temp", 

571 "urn:xmpp:ping", 

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

573 "jabber:iq:register", 

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

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

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

577 ] 

578 if self.type == MucType.GROUP: 

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

580 elif self.type == MucType.CHANNEL: 

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

582 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

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

584 

585 try: # oh boy, this sucks 

586 has_space = self.stored.space is not None 

587 except DetachedInstanceError: 

588 self.refresh() 

589 has_space = self.stored.space is not None 

590 

591 if has_space: 

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

593 return features 

594 

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

596 is_group = self.type == MucType.GROUP 

597 

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

599 

600 form.add_field( 

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

602 ) 

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

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

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

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

607 

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

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

610 n = orm.scalar( 

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

612 room_id=self.stored.id 

613 ) 

614 ) 

615 else: 

616 n = self.n_participants 

617 if n is not None: 

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

619 

620 if d := self.description: 

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

622 

623 if s := self.subject: 

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

625 

626 if name := self.name: 

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

628 

629 if self._set_avatar_task is not None: 

630 await self._set_avatar_task 

631 avatar = self.get_avatar() 

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

633 form.add_field( 

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

635 ) 

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

637 if avatar.url: 

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

639 

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

641 form.add_field( 

642 "muc#roomconfig_whois", 

643 "list-single", 

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

645 ) 

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

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

648 

649 r = [form] 

650 

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

652 r.append(reaction_form) 

653 

654 if self.stored.space is not None: 

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

656 self.stored.space.legacy_id 

657 ) 

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

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

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

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

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

663 

664 return r 

665 

666 def shutdown(self) -> None: 

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

668 for user_full_jid in self.user_full_jids(): 

669 presence = self.xmpp.make_presence( 

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

671 ) 

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

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

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

675 presence.send() 

676 

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

678 for r in self.get_user_resources(): 

679 j = JID(self.user_jid) 

680 j.resource = r 

681 yield j 

682 

683 @property 

684 def user_muc_jid(self) -> JID: 

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

686 return user_muc_jid 

687 

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

689 msg.set_from(self.user_muc_jid) 

690 if legacy_msg_id: 

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

692 else: 

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

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

695 

696 user_part = await self.get_user_participant() 

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

698 

699 self.archive.add(msg, user_part) 

700 

701 for user_full_jid in self.user_full_jids(): 

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

703 msg = copy(msg) 

704 msg.set_to(user_full_jid) 

705 

706 msg.send() 

707 

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

709 

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

711 self.__send_configuration_change((104,)) 

712 self._send_room_presence() 

713 

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

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

716 for to in tos: 

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

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

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

720 if avatar.http_metadata is not None: 

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

722 metadata.append(avatar.http_metadata) 

723 p.append(metadata) 

724 else: 

725 p["vcard_temp_update"]["photo"] = "" 

726 p.send() 

727 

728 @timeit 

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

730 user_full_jid = join_presence.get_from() 

731 requested_nickname = join_presence.get_to().resource 

732 client_resource = user_full_jid.resource 

733 

734 if client_resource in self.get_user_resources(): 

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

736 

737 if not requested_nickname or not client_resource: 

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

739 

740 self.add_user_resource(client_resource) 

741 

742 self.log.debug( 

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

744 client_resource, 

745 self.user_jid, 

746 self.legacy_id, 

747 requested_nickname, 

748 ) 

749 

750 user_nick = self.user_nick 

751 user_participant = None 

752 await self.__fill_participants() 

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

754 mav_until = await self.__get_mav() 

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

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

757 self.log.debug( 

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

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

760 mav_until, 

761 ) 

762 await self.__send_mav(user_full_jid, mav_until) 

763 else: 

764 mav_until = None 

765 async for participant in self.get_participants(): 

766 if participant.is_user: 

767 user_participant = participant 

768 continue 

769 participant.send_initial_presence(full_jid=user_full_jid) 

770 

771 if user_participant is None: 

772 user_participant = await self.get_user_participant() 

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

774 orm.add(self.stored) 

775 with orm.no_autoflush: 

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

777 if not user_participant.is_user: 

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

779 user_participant.is_user = True 

780 user_participant.send_initial_presence( 

781 user_full_jid, 

782 presence_id=join_presence["id"], 

783 nick_change=user_nick != requested_nickname, 

784 mav_until=mav_until, 

785 ) 

786 

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

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

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

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

791 try: 

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

793 except ValueError: 

794 since = None 

795 if seconds is not None: 

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

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

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

799 else: 

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

801 await self.__fill_history() 

802 await self.__old_school_history( 

803 user_full_jid, 

804 maxchars=maxchars, 

805 maxstanzas=maxstanzas, 

806 since=since, 

807 ) 

808 if self.HAS_SUBJECT: 

809 subject = self.subject or "" 

810 else: 

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

812 self.__get_subject_setter_participant().set_room_subject( 

813 subject, 

814 user_full_jid, 

815 self.subject_date, 

816 ) 

817 if t := self._set_avatar_task: 

818 await t 

819 self._send_room_presence(user_full_jid) 

820 

821 async def __get_mav(self) -> str: 

822 data = self.__get_mav_data() 

823 return self.__compute_mav_ver(data) 

824 

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

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

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

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

829 for part in self.stored.participants: 

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

831 continue 

832 if part.affiliation == "none": 

833 continue 

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

835 return data 

836 

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

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

839 

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

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

842 affs = [] 

843 for id_, aff in data: 

844 if aff == "none": 

845 continue 

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

847 affs.sort() 

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

849 

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

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

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

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

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

855 for part in self.stored.participants: 

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

857 continue 

858 item = MUCUserItem() 

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

860 item["affiliation"] = part.affiliation 

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

862 msg.send() 

863 

864 async def get_user_participant( 

865 self, 

866 *, 

867 fill_first: bool = False, 

868 store: bool = True, 

869 occupant_id: str | None = None, 

870 ) -> "LegacyParticipantType": 

871 """ 

872 Get the participant representing the gateway user 

873 

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

875 construction (optional) 

876 :return: 

877 """ 

878 p = await self.get_participant( 

879 self.user_nick, 

880 is_user=True, 

881 fill_first=fill_first, 

882 store=store, 

883 occupant_id=occupant_id, 

884 ) 

885 self.__store_participant(p) 

886 return p 

887 

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

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

890 return 

891 try: 

892 p.commit() 

893 except IntegrityError as e: 

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

895 self.stored = p.stored.room 

896 

897 @overload 

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

899 

900 @overload 

901 async def get_participant( 

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

903 ) -> "LegacyParticipantType": ... 

904 

905 @overload 

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

907 

908 @overload 

909 async def get_participant( 

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

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

912 

913 @overload 

914 async def get_participant( 

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

916 ) -> "LegacyParticipantType": ... 

917 

918 @overload 

919 async def get_participant( 

920 self, nickname: str, *, occupant_id: str 

921 ) -> "LegacyParticipantType": ... 

922 

923 @overload 

924 async def get_participant( 

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

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

927 

928 @overload 

929 async def get_participant( 

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

931 ) -> "LegacyParticipantType": ... 

932 

933 @overload 

934 async def get_participant( 

935 self, 

936 nickname: str, 

937 *, 

938 create: Literal[True], 

939 is_user: bool, 

940 fill_first: bool, 

941 store: bool, 

942 ) -> "LegacyParticipantType": ... 

943 

944 @overload 

945 async def get_participant( 

946 self, 

947 nickname: str, 

948 *, 

949 create: Literal[False], 

950 is_user: bool, 

951 fill_first: bool, 

952 store: bool, 

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

954 

955 @overload 

956 async def get_participant( 

957 self, 

958 nickname: str, 

959 *, 

960 create: bool, 

961 fill_first: bool, 

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

963 

964 @overload 

965 async def get_participant( 

966 self, 

967 nickname: str, 

968 *, 

969 is_user: Literal[True], 

970 fill_first: bool, 

971 store: bool, 

972 occupant_id: str | None = None, 

973 ) -> "LegacyParticipantType": ... 

974 

975 async def get_participant( 

976 self, 

977 nickname: str | None = None, 

978 *, 

979 create: bool = True, 

980 is_user: bool = False, 

981 fill_first: bool = False, 

982 store: bool = True, 

983 occupant_id: str | None = None, 

984 ) -> "LegacyParticipantType | None": 

985 """ 

986 Get a participant by their nickname. 

987 

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

989 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

990 

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

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

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

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

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

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

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

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

999 xep:`0421` 

1000 :return: A participant of this room. 

1001 """ 

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

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

1004 if fill_first: 

1005 await self.__fill_participants() 

1006 if self.stored.id is not None: 

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

1008 if occupant_id is not None: 

1009 stored = ( 

1010 orm.query(Participant) 

1011 .filter( 

1012 Participant.room == self.stored, 

1013 Participant.occupant_id == occupant_id, 

1014 ) 

1015 .one_or_none() 

1016 ) 

1017 elif nickname is not None: 

1018 stored = ( 

1019 orm.query(Participant) 

1020 .filter( 

1021 Participant.room == self.stored, 

1022 (Participant.nickname == nickname) 

1023 | (Participant.resource == nickname), 

1024 ) 

1025 .one_or_none() 

1026 ) 

1027 else: 

1028 raise RuntimeError("NEVER") 

1029 if stored is not None: 

1030 if occupant_id and occupant_id != stored.occupant_id: 

1031 warnings.warn( 

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

1033 ) 

1034 part = self.participant_from_store(stored) 

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

1036 stored.nickname = nickname 

1037 orm.add(stored) 

1038 orm.commit() 

1039 return part 

1040 

1041 if not create: 

1042 return None 

1043 

1044 if occupant_id is None: 

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

1046 

1047 if nickname is None: 

1048 nickname = occupant_id 

1049 

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

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

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

1053 if is_user: 

1054 self.user_nick = nickname 

1055 

1056 p = self.participant_cls( 

1057 self, 

1058 Participant( 

1059 room=self.stored, 

1060 nickname=nickname or occupant_id, 

1061 is_user=is_user, 

1062 occupant_id=occupant_id, 

1063 ), 

1064 ) 

1065 if store: 

1066 self.__store_participant(p) 

1067 self.send_affiliation_change(p) 

1068 return p 

1069 

1070 def send_affiliation_change( 

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

1072 ) -> None: 

1073 # internal use by slidge 

1074 if not self.participants_filled: 

1075 return 

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

1077 return 

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

1079 return 

1080 if part.contact is None: 

1081 return 

1082 if part.is_system: 

1083 return 

1084 if was == part.affiliation: 

1085 return 

1086 system_part = self.get_system_participant() 

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

1088 data = self.__get_mav_data() 

1089 since_data = [ 

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

1091 for id_, aff in data 

1092 ] 

1093 if part.affiliation == "none": 

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

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

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

1097 item = MUCUserItem() 

1098 item["affiliation"] = part.affiliation 

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

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

1101 system_part._send(msg) 

1102 

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

1104 """ 

1105 Get a pseudo-participant, representing the room itself 

1106 

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

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

1109 service 

1110 :return: 

1111 """ 

1112 return self.participant_cls( 

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

1114 ) 

1115 

1116 @overload 

1117 async def get_participant_by_contact( 

1118 self, c: "LegacyContact" 

1119 ) -> "LegacyParticipantType": ... 

1120 

1121 @overload 

1122 async def get_participant_by_contact( 

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

1124 ) -> "LegacyParticipantType": ... 

1125 

1126 @overload 

1127 async def get_participant_by_contact( 

1128 self, 

1129 c: "LegacyContact", 

1130 *, 

1131 create: Literal[False], 

1132 occupant_id: str | None, 

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

1134 

1135 @overload 

1136 async def get_participant_by_contact( 

1137 self, 

1138 c: "LegacyContact", 

1139 *, 

1140 create: Literal[True], 

1141 occupant_id: str | None, 

1142 ) -> "LegacyParticipantType": ... 

1143 

1144 async def get_participant_by_contact( 

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

1146 ) -> "LegacyParticipantType | None": 

1147 """ 

1148 Get a non-anonymous participant. 

1149 

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

1151 that the Contact jid is associated to this participant 

1152 

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

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

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

1156 this participant. 

1157 :return: 

1158 """ 

1159 await self.session.contacts.ready 

1160 

1161 if self.stored.id is not None: 

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

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

1164 stored = ( 

1165 orm.query(Participant) 

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

1167 .one_or_none() 

1168 ) 

1169 if stored is None: 

1170 if occupant_id is not None: 

1171 stored = ( 

1172 orm.query(Participant) 

1173 .filter_by( 

1174 occupant_id=occupant_id, 

1175 room=self.stored, 

1176 contact_id=None, 

1177 ) 

1178 .one_or_none() 

1179 ) 

1180 if stored is not None: 

1181 self.log.debug( 

1182 "Updating the contact of a previously anonymous participant" 

1183 ) 

1184 stored.contact_id = c.stored.id 

1185 orm.add(stored) 

1186 orm.commit() 

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

1188 if not create: 

1189 return None 

1190 else: 

1191 if occupant_id and stored.occupant_id != occupant_id: 

1192 warnings.warn( 

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

1194 ) 

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

1196 

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

1198 

1199 if self.stored.id is None: 

1200 nick_available = True 

1201 else: 

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

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

1204 orm, self.stored.id, nickname 

1205 ) 

1206 

1207 if not nick_available: 

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

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

1210 p = self.participant_cls( 

1211 self, 

1212 Participant( 

1213 nickname=nickname, 

1214 room=self.stored, 

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

1216 ), 

1217 contact=c, 

1218 ) 

1219 

1220 self.__store_participant(p) 

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

1222 # during participants fill and history backfill we do not 

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

1224 # and role afterwards. 

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

1226 if ( 

1227 self.participants_filled 

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

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

1230 ): 

1231 self.send_affiliation_change(p) 

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

1233 return p 

1234 

1235 @overload 

1236 async def get_participant_by_legacy_id( 

1237 self, legacy_id: str 

1238 ) -> "LegacyParticipantType": ... 

1239 

1240 @overload 

1241 async def get_participant_by_legacy_id( 

1242 self, 

1243 legacy_id: str, 

1244 *, 

1245 occupant_id: str | None, 

1246 create: Literal[True], 

1247 ) -> "LegacyParticipantType": ... 

1248 

1249 @overload 

1250 async def get_participant_by_legacy_id( 

1251 self, 

1252 legacy_id: str, 

1253 *, 

1254 occupant_id: str | None, 

1255 ) -> "LegacyParticipantType": ... 

1256 

1257 @overload 

1258 async def get_participant_by_legacy_id( 

1259 self, 

1260 legacy_id: str, 

1261 *, 

1262 occupant_id: str | None, 

1263 create: Literal[False], 

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

1265 

1266 async def get_participant_by_legacy_id( 

1267 self, 

1268 legacy_id: str, 

1269 *, 

1270 occupant_id: str | None = None, 

1271 create: bool = True, 

1272 ) -> "LegacyParticipantType": 

1273 try: 

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

1275 except ContactIsUser: 

1276 return await self.get_user_participant(occupant_id=occupant_id) 

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

1278 c, create=create, occupant_id=occupant_id 

1279 ) 

1280 

1281 def remove_participant( 

1282 self, 

1283 p: "LegacyParticipantType", 

1284 kick: bool = False, 

1285 ban: bool = False, 

1286 reason: str | None = None, 

1287 ) -> None: 

1288 """ 

1289 Call this when a participant leaves the room 

1290 

1291 :param p: The participant 

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

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

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

1295 """ 

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

1297 if kick and ban: 

1298 raise TypeError("Either kick or ban") 

1299 if kick: 

1300 codes = {307} 

1301 elif ban: 

1302 codes = {301} 

1303 else: 

1304 codes = None 

1305 was = p.stored.affiliation 

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

1307 p.stored.role = "none" 

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

1309 self.send_affiliation_change(p, was) 

1310 if reason: 

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

1312 p._send(presence, force=True) 

1313 with self.orm() as orm: 

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

1315 orm.commit() 

1316 

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

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

1319 stored = ( 

1320 orm.query(Participant) 

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

1322 .one_or_none() 

1323 ) 

1324 if stored is None: 

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

1326 return 

1327 p = self.participant_from_store(stored) 

1328 if p.nickname == old_nickname: 

1329 p.nickname = new_nickname 

1330 

1331 async def __old_school_history( 

1332 self, 

1333 full_jid: JID, 

1334 maxchars: int | None = None, 

1335 maxstanzas: int | None = None, 

1336 seconds: int | None = None, 

1337 since: datetime | None = None, 

1338 ) -> None: 

1339 """ 

1340 Old-style history join (internal slidge use) 

1341 

1342 :param full_jid: 

1343 :param maxchars: 

1344 :param maxstanzas: 

1345 :param seconds: 

1346 :param since: 

1347 :return: 

1348 """ 

1349 if since is None: 

1350 if seconds is None: 

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

1352 else: 

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

1354 else: 

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

1356 

1357 for h_msg in self.archive.get_all( 

1358 start_date=start_date, end_date=None, last_page_n=maxstanzas 

1359 ): 

1360 msg = h_msg.stanza_component_ns 

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

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

1363 msg.set_to(full_jid) 

1364 self.xmpp.send(msg, False) 

1365 

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

1367 await self.__fill_history() 

1368 

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

1370 

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

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

1373 

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

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

1376 

1377 sender = form_values.get("with") 

1378 

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

1380 

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

1382 try: 

1383 max_results = int(max_str) 

1384 except ValueError: 

1385 max_results = None 

1386 else: 

1387 max_results = None 

1388 

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

1390 after_id = after_id_rsm or after_id 

1391 

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

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

1394 last_page_n = max_results 

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

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

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

1398 if before_rsm is not True: 

1399 before_id = before_rsm 

1400 else: 

1401 last_page_n = None 

1402 

1403 first = None 

1404 last = None 

1405 count = 0 

1406 

1407 it = self.archive.get_all( 

1408 start_date, 

1409 end_date, 

1410 before_id, 

1411 after_id, 

1412 ids, 

1413 last_page_n, 

1414 sender, 

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

1416 ) 

1417 

1418 for history_msg in it: 

1419 last = xmpp_id = history_msg.id 

1420 if first is None: 

1421 first = xmpp_id 

1422 

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

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

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

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

1427 

1428 wrapper_msg.send() 

1429 count += 1 

1430 

1431 if max_results and count == max_results: 

1432 break 

1433 

1434 if max_results: 

1435 try: 

1436 next(it) 

1437 except StopIteration: 

1438 complete = True 

1439 else: 

1440 complete = False 

1441 else: 

1442 complete = True 

1443 

1444 reply = iq.reply() 

1445 if not self.STABLE_ARCHIVE: 

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

1447 if complete: 

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

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

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

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

1452 reply.send() 

1453 

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

1455 await self.__fill_history() 

1456 await self.archive.send_metadata(iq) 

1457 

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

1459 """ 

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

1461 

1462 :param r: The resource to kick 

1463 """ 

1464 pto = JID(self.user_jid) 

1465 pto.resource = r 

1466 p = self.xmpp.make_presence( 

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

1468 ) 

1469 p["type"] = "unavailable" 

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

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

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

1473 p.send() 

1474 

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

1476 item = Item() 

1477 item["id"] = self.jid 

1478 

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

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

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

1482 

1483 try: 

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

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

1486 return None 

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

1488 # (slixmpp annoying magic) 

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

1490 item["id"] = self.jid 

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

1492 except IqTimeout: 

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

1494 return None 

1495 except IqError as exc: 

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

1497 return None 

1498 except PermissionError: 

1499 warnings.warn( 

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

1501 ) 

1502 return None 

1503 

1504 async def add_to_bookmarks( 

1505 self, 

1506 auto_join: bool = True, 

1507 preserve: bool = True, 

1508 pin: bool | None = None, 

1509 notify: WhenLiteral | None = None, 

1510 ) -> None: 

1511 """ 

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

1513 

1514 This requires that slidge has the IQ privileged set correctly 

1515 on the XMPP server 

1516 

1517 :param auto_join: whether XMPP clients should automatically join 

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

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

1520 join if they are online. 

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

1522 set by the user outside slidge 

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

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

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

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

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

1528 """ 

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

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

1531 

1532 new = Item() 

1533 new["id"] = self.jid 

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

1535 

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

1537 "always_invite_when_adding_bookmarks", True 

1538 ) 

1539 if existing is None: 

1540 change = True 

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

1542 else: 

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

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

1545 invite = False 

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

1547 

1548 existing_extensions = ( 

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

1550 ) 

1551 

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

1553 if existing_extensions: 

1554 assert existing is not None 

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

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

1557 continue 

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

1559 continue 

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

1561 

1562 if pin is not None: 

1563 if existing_extensions: 

1564 assert existing is not None 

1565 existing_pin = ( 

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

1567 "pinned", check=True 

1568 ) 

1569 is not None 

1570 ) 

1571 if existing_pin != pin: 

1572 change = True 

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

1574 

1575 if notify is not None: 

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

1577 if existing_extensions: 

1578 assert existing is not None 

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

1580 "notify", check=True 

1581 ) 

1582 if existing_notify is None: 

1583 change = True 

1584 else: 

1585 if existing_notify.get_config() != notify: 

1586 change = True 

1587 for el in existing_notify: 

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

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

1590 

1591 if change: 

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

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

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

1595 

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

1597 

1598 update_success = False 

1599 try: 

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

1601 except PermissionError: 

1602 warnings.warn( 

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

1604 ) 

1605 except IqError as e: 

1606 warnings.warn( 

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

1608 ) 

1609 else: 

1610 update_success = True 

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

1612 self.session.send_gateway_invite( 

1613 self, 

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

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

1616 "Contact your administrator.", 

1617 ) 

1618 return 

1619 else: 

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

1621 

1622 if invite: 

1623 self.session.send_gateway_invite( 

1624 self.jid, 

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

1626 ) 

1627 

1628 async def remove_from_bookmarks(self) -> None: 

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

1630 

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

1632 side-effect. 

1633 

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

1635 """ 

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

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

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

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

1640 try: 

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

1642 except PermissionError: 

1643 warnings.warn( 

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

1645 "we cannot remove bookmarks for the user" 

1646 ) 

1647 except IqError as e: 

1648 warnings.warn( 

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

1650 ) 

1651 

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

1653 """ 

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

1655 client. 

1656 

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

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

1659 updated on the XMPP side. 

1660 

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

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

1663 room update event. 

1664 

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

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

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

1668 correct. 

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

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

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

1672 """ 

1673 raise NotImplementedError 

1674 

1675 async def on_set_config( 

1676 self, 

1677 name: str | None, 

1678 description: str | None, 

1679 ) -> None: 

1680 """ 

1681 Triggered when the user requests changing the room configuration. 

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

1683 

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

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

1686 

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

1688 be ``None``. 

1689 

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

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

1692 """ 

1693 raise NotImplementedError 

1694 

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

1696 """ 

1697 Triggered when the user requests room destruction. 

1698 

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

1700 """ 

1701 raise NotImplementedError 

1702 

1703 async def parse_mentions( 

1704 self, text: str | None 

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

1706 if not text: 

1707 return () 

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

1709 await self.__fill_participants() 

1710 orm.add(self.stored) 

1711 participants = { 

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

1713 } 

1714 

1715 if len(participants) == 0: 

1716 return () 

1717 

1718 result = [] 

1719 for match in re.finditer( 

1720 "|".join( 

1721 sorted( 

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

1723 key=lambda nick: len(nick), 

1724 reverse=True, 

1725 ) 

1726 ), 

1727 text, 

1728 ): 

1729 span = match.span() 

1730 nick = match.group() 

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

1732 continue 

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

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

1735 result.append( 

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

1737 ) 

1738 return tuple(result) 

1739 

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

1741 """ 

1742 Triggered when the user requests changing the room subject. 

1743 

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

1745 instance. 

1746 

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

1748 """ 

1749 raise NotImplementedError 

1750 

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

1752 """ 

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

1754 

1755 :param thread: Legacy identifier of the thread 

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

1757 """ 

1758 raise NotImplementedError 

1759 

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

1761 """ 

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

1763 a MUC using :xep:`0425`. 

1764 

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

1766 XMPPError with a human-readable message. 

1767 

1768 NB: the legacy module is responsible for calling 

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

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

1771 moderation message from the MUC automatically. 

1772 

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

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

1775 user-moderator. 

1776 """ 

1777 raise NotImplementedError 

1778 

1779 async def on_leave(self) -> None: 

1780 """ 

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

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

1783 

1784 This should be interpreted as definitely leaving the group. 

1785 """ 

1786 raise NotImplementedError 

1787 

1788 @property 

1789 def participants_filled(self) -> bool: 

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

1791 # has been called… 

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

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

1794 # default no-op implementation. 

1795 return True 

1796 try: 

1797 return self.stored.participants_filled 

1798 except DetachedInstanceError: 

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

1800 orm.add(self.stored) 

1801 with orm.no_autoflush: 

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

1803 return self.stored.participants_filled 

1804 

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

1806 """ 

1807 Query the slidge archive for messages sent in this group 

1808 

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

1810 or an XMPP ID. 

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

1812 because of multi-attachment messages. 

1813 """ 

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

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

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

1817 ): 

1818 yield HistoryMessage(stored.stanza) 

1819 

1820 

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

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

1823 sub.attrib["id"] = origin_id 

1824 msg.xml.append(sub) 

1825 

1826 

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

1828 try: 

1829 return int(x) 

1830 except ValueError: 

1831 return None 

1832 

1833 

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

1835 if x is None: 

1836 return False 

1837 else: 

1838 return x == 0 

1839 

1840 

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

1842 if date is None: 

1843 return None 

1844 try: 

1845 return str_to_datetime(date) 

1846 except ValueError: 

1847 return None 

1848 

1849 

1850def bookmarks_form() -> Form: 

1851 form = Form() 

1852 form["type"] = "submit" 

1853 form.add_field( 

1854 "FORM_TYPE", 

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

1856 ftype="hidden", 

1857 ) 

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

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

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

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

1862 return form 

1863 

1864 

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

1866 

1867_BOOKMARKS_OPTIONS = bookmarks_form() 

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

1869 

1870log = logging.getLogger(__name__)