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

910 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-20 19:56 +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 ( 

14 TYPE_CHECKING, 

15 Any, 

16 ClassVar, 

17 Generic, 

18 Literal, 

19 Union, 

20 overload, 

21) 

22 

23import sqlalchemy as sa 

24from slixmpp import JID, Iq, Message, Presence 

25from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

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

27from slixmpp.plugins.xep_0045.stanza import MUCUserItem 

28from slixmpp.plugins.xep_0060.stanza import Item 

29from slixmpp.plugins.xep_0082 import parse as str_to_datetime 

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

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

32from slixmpp.plugins.xep_0492.stanza import WhenLiteral 

33from slixmpp.xmlstream import ET 

34from sqlalchemy.exc import IntegrityError 

35from sqlalchemy.orm import Session as OrmSession 

36from sqlalchemy.orm.exc import DetachedInstanceError 

37 

38from ..contact.contact import LegacyContact 

39from ..contact.roster import ContactIsUser 

40from ..core.mixins.avatar import AvatarMixin 

41from ..core.mixins.disco import ChatterDiscoMixin 

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

43from ..db.models import Participant, Room 

44from ..util.archive_msg import HistoryMessage 

45from ..util.jid_escaping import unescape_node 

46from ..util.types import ( 

47 AnyContact, 

48 AnySession, 

49 HoleBound, 

50 LegacyGroupIdType, 

51 LegacyMessageType, 

52 LegacyParticipantType, 

53 LegacyThreadType, 

54 LegacyUserIdType, 

55 Mention, 

56 MucAffiliation, 

57 MucType, 

58) 

59from ..util.util import SubclassableOnce, deprecated, timeit 

60from .archive import MessageArchive 

61from .participant import LegacyParticipant, escape_nickname 

62 

63if TYPE_CHECKING: 

64 from ..command.base import MUCCommand 

65 from ..db.avatar import CachedAvatar 

66 

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

68 

69SubjectSetterType = Union[str, None, AnyContact, "LegacyParticipant"] 

70 

71 

72class LegacyMUC( 

73 Generic[ 

74 LegacyGroupIdType, LegacyMessageType, LegacyParticipantType, LegacyUserIdType 

75 ], 

76 AvatarMixin, 

77 ChatterDiscoMixin, 

78 ReactionRecipientMixin, 

79 ThreadRecipientMixin, 

80 SubclassableOnce, 

81): 

82 """ 

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

84 

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

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

87 """ 

88 

89 max_history_fetch = 100 

90 

91 is_group = True 

92 

93 DISCO_TYPE = "text" 

94 DISCO_CATEGORY = "conference" 

95 

96 STABLE_ARCHIVE = False 

97 """ 

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

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

100 across restarts. 

101 

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

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

104 

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

106 """ 

107 

108 """ 

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

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

111 """ 

112 

113 HAS_DESCRIPTION = True 

114 """ 

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

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

117 room configuration form. 

118 """ 

119 

120 HAS_SUBJECT = True 

121 """ 

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

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

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

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

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

127 tries to set the room subject. 

128 """ 

129 

130 archive: MessageArchive 

131 session: AnySession 

132 

133 stored: Room 

134 

135 commands: ClassVar[dict[str, "type[MUCCommand]"]] = {} # type:ignore[type-arg] 

136 commands_chat: ClassVar[dict[str, "type[MUCCommand]"]] = {} # type:ignore[type-arg] 

137 

138 _participant_cls: type[LegacyParticipantType] 

139 

140 is_participant: Literal[False] = False 

141 

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

143 self.session = session 

144 self.xmpp = session.xmpp 

145 self.stored = stored 

146 self._set_logger() 

147 super().__init__() 

148 

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

150 

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

152 """ 

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

154 

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

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

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

158 

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

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

161 MUC will be marked as read. 

162 

163 :param horizon_xmpp_id: The latest message 

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

165 """ 

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

167 assert self.stored.id is not None 

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

169 orm, self.stored.id, horizon_xmpp_id 

170 ) 

171 orm.commit() 

172 return ids 

173 

174 def participant_from_store( 

175 self, stored: Participant, contact: AnyContact | None = None 

176 ) -> LegacyParticipantType: 

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

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

179 return self._participant_cls(self, stored=stored, contact=contact) 

180 

181 @property 

182 def jid(self) -> JID: 

183 return self.stored.jid 

184 

185 @jid.setter 

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

187 # FIXME: without this, mypy yields 

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

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

190 raise RuntimeError 

191 

192 @property 

193 def legacy_id(self) -> LegacyGroupIdType: 

194 return self.xmpp.LEGACY_ROOM_ID_TYPE(self.stored.legacy_id) # type:ignore[no-any-return] 

195 

196 @property 

197 def space_legacy_id(self) -> LegacyGroupIdType: 

198 return self.xmpp.LEGACY_ROOM_ID_TYPE(self.stored.space.legacy_id) # type:ignore[no-any-return] 

199 

200 @space_legacy_id.setter 

201 def space_legacy_id(self, legacy_id: LegacyGroupIdType) -> None: 

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

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

204 self.stored.space = space 

205 if self._updating_info: 

206 with orm.no_autoflush: 

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

208 return 

209 orm.add(self.stored) 

210 orm.commit() 

211 

212 def orm( 

213 self, 

214 **kwargs: Any, # noqa:ANN401 

215 ) -> OrmSession: 

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

217 

218 @property 

219 def type(self) -> MucType: 

220 return self.stored.muc_type 

221 

222 @type.setter 

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

224 if self.type == type_: 

225 return 

226 self.update_stored_attribute(muc_type=type_) 

227 

228 @property 

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

230 return self.stored.n_participants 

231 

232 @n_participants.setter 

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

234 if self.stored.n_participants == n_participants: 

235 return 

236 self.update_stored_attribute(n_participants=n_participants) 

237 

238 @property 

239 def user_jid(self) -> JID: 

240 return self.session.user_jid 

241 

242 def _set_logger(self) -> None: 

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

244 

245 def __repr__(self) -> str: 

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

247 

248 @property 

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

250 if self.stored.subject_date is None: 

251 return None 

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

253 

254 @subject_date.setter 

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

256 if self.subject_date == when: 

257 return 

258 self.update_stored_attribute(subject_date=when) 

259 

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

261 part = self.get_system_participant() 

262 part.send_configuration_change(codes) 

263 

264 @property 

265 def user_nick(self) -> str: 

266 return ( 

267 self.stored.user_nick 

268 or self.session.bookmarks.user_nick 

269 or self.user_jid.node 

270 ) 

271 

272 @user_nick.setter 

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

274 if nick == self.user_nick: 

275 return 

276 self.update_stored_attribute(user_nick=nick) 

277 

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

279 stored_set = self.get_user_resources() 

280 if resource in stored_set: 

281 return 

282 stored_set.add(resource) 

283 self.update_stored_attribute( 

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

285 ) 

286 

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

288 stored_str = self.stored.user_resources 

289 if stored_str is None: 

290 return set() 

291 return set(json.loads(stored_str)) 

292 

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

294 stored_set = self.get_user_resources() 

295 if resource not in stored_set: 

296 return 

297 stored_set.remove(resource) 

298 self.update_stored_attribute( 

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

300 ) 

301 

302 @asynccontextmanager 

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

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

305 yield 

306 

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

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

309 

310 async def __fill_participants(self) -> None: 

311 if self.participants_filled: 

312 return 

313 

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

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

316 orm.add(self.stored) 

317 with orm.no_autoflush: 

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

319 if self.participants_filled: 

320 return 

321 parts: list[Participant] = [] 

322 resources = set[str]() 

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

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

325 user_found = False 

326 async for participant in self.fill_participants(): 

327 if participant.is_user: 

328 user_found = True 

329 if participant.stored.resource in resources: 

330 self.log.debug( 

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

332 participant.stored.resource, 

333 ) 

334 continue 

335 parts.append(participant.stored) 

336 resources.add(participant.stored.resource) 

337 

338 if not user_found: 

339 participant = await self.get_user_participant() 

340 if participant.stored.resource in resources: 

341 for p in parts: 

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

343 p.is_user = True 

344 else: 

345 parts.append(participant.stored) 

346 resources.add(participant.stored.resource) 

347 

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

349 orm.add(self.stored) 

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

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

352 # and the participant_filled attribute. 

353 with orm.no_autoflush: 

354 orm.refresh(self.stored) 

355 for part in parts: 

356 orm.merge(part) 

357 self.stored.participants_filled = True 

358 orm.commit() 

359 

360 async def get_participants( 

361 self, affiliation: MucAffiliation | None = None 

362 ) -> AsyncIterator[LegacyParticipantType]: 

363 await self.__fill_participants() 

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

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

366 db_participants = self.stored.participants 

367 for db_participant in db_participants: 

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

369 continue 

370 yield self.participant_from_store(db_participant) 

371 

372 async def __fill_history(self) -> None: 

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

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

375 orm.add(self.stored) 

376 with orm.no_autoflush: 

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

378 if self.stored.history_filled: 

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

380 return 

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

382 try: 

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

384 if before is not None: 

385 before = before._replace( 

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

387 ) 

388 if after is not None: 

389 after = after._replace( 

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

391 ) 

392 await self.backfill(before, after) 

393 except NotImplementedError: 

394 return 

395 except Exception as e: 

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

397 

398 self.stored.history_filled = True 

399 self.commit() 

400 

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

402 return self.name 

403 

404 @property 

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

406 return self.stored.name 

407 

408 @name.setter 

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

410 if self.name == n: 

411 return 

412 self.update_stored_attribute(name=n) 

413 self._set_logger() 

414 self.__send_configuration_change((104,)) 

415 

416 @property 

417 def description(self) -> str: 

418 return self.stored.description or "" 

419 

420 @description.setter 

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

422 if self.description == d: 

423 return 

424 self.update_stored_attribute(description=d) 

425 self.__send_configuration_change((104,)) 

426 

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

428 pto = p.get_to() 

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

430 return 

431 

432 pfrom = p.get_from() 

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

434 return 

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

436 if pto.resource != self.user_nick: 

437 self.log.debug( 

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

439 ) 

440 self.remove_user_resource(resource) 

441 else: 

442 self.log.debug( 

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

444 ) 

445 

446 async def update_info(self) -> None: 

447 """ 

448 Fetch information about this group from the legacy network 

449 

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

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

452 of participants etc. 

453 

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

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

456 is no change, you should not call 

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

458 attempt to modify 

459 the :attr:.avatar property. 

460 """ 

461 raise NotImplementedError 

462 

463 async def backfill( 

464 self, 

465 after: HoleBound | None = None, 

466 before: HoleBound | None = None, 

467 ) -> None: 

468 """ 

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

470 

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

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

473 run for a given group. 

474 

475 :param after: Fetch messages after this one. 

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

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

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

479 the user registered. 

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

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

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

483 :param before: Fetch messages before this one. 

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

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

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

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

488 """ 

489 raise NotImplementedError 

490 

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

492 """ 

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

494 

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

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

497 before yielding them. 

498 """ 

499 return 

500 yield 

501 

502 @property 

503 def subject(self) -> str: 

504 return self.stored.subject or "" 

505 

506 @subject.setter 

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

508 if s == self.subject: 

509 return 

510 

511 self.update_stored_attribute(subject=s) 

512 self.__get_subject_setter_participant().set_room_subject( 

513 s, None, self.subject_date, False 

514 ) 

515 

516 @property 

517 def is_anonymous(self) -> bool: 

518 return self.type == MucType.CHANNEL 

519 

520 @property 

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

522 return self.stored.subject_setter 

523 

524 @subject_setter.setter 

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

526 if isinstance(subject_setter, LegacyContact): 

527 subject_setter = subject_setter.name 

528 elif isinstance(subject_setter, LegacyParticipant): 

529 subject_setter = subject_setter.nickname 

530 

531 if subject_setter == self.subject_setter: 

532 return 

533 assert isinstance(subject_setter, str | None) 

534 self.update_stored_attribute(subject_setter=subject_setter) 

535 

536 def __get_subject_setter_participant(self) -> LegacyParticipant: 

537 if self.subject_setter is None: 

538 return self.get_system_participant() 

539 return self._participant_cls( 

540 self, 

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

542 ) 

543 

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

545 features = [ 

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

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

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

549 "urn:xmpp:mam:2", 

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

551 "urn:xmpp:sid:0", 

552 "muc_persistent", 

553 "vcard-temp", 

554 "urn:xmpp:ping", 

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

556 "jabber:iq:register", 

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

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

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

560 ] 

561 if self.type == MucType.GROUP: 

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

563 elif self.type == MucType.CHANNEL: 

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

565 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

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

567 

568 try: # oh boy, this sucks 

569 has_space = self.stored.space is not None 

570 except DetachedInstanceError: 

571 self.refresh() 

572 has_space = self.stored.space is not None 

573 

574 if has_space: 

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

576 return features 

577 

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

579 is_group = self.type == MucType.GROUP 

580 

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

582 

583 form.add_field( 

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

585 ) 

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

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

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

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

590 

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

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

593 n = orm.scalar( 

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

595 room_id=self.stored.id 

596 ) 

597 ) 

598 else: 

599 n = self.n_participants 

600 if n is not None: 

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

602 

603 if d := self.description: 

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

605 

606 if s := self.subject: 

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

608 

609 if name := self.name: 

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

611 

612 if self._set_avatar_task is not None: 

613 await self._set_avatar_task 

614 avatar = self.get_avatar() 

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

616 form.add_field( 

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

618 ) 

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

620 

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

622 form.add_field( 

623 "muc#roomconfig_whois", 

624 "list-single", 

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

626 ) 

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

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

629 

630 r = [form] 

631 

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

633 r.append(reaction_form) 

634 

635 if self.stored.space is not None: 

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

637 self.stored.space.legacy_id 

638 ) 

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

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

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

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

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

644 

645 return r 

646 

647 def shutdown(self) -> None: 

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

649 for user_full_jid in self.user_full_jids(): 

650 presence = self.xmpp.make_presence( 

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

652 ) 

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

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

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

656 presence.send() 

657 

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

659 for r in self.get_user_resources(): 

660 j = JID(self.user_jid) 

661 j.resource = r 

662 yield j 

663 

664 @property 

665 def user_muc_jid(self) -> JID: 

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

667 return user_muc_jid 

668 

669 async def echo( 

670 self, msg: Message, legacy_msg_id: LegacyMessageType | None = None 

671 ) -> str: 

672 msg.set_from(self.user_muc_jid) 

673 if legacy_msg_id: 

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

675 else: 

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

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

678 

679 user_part = await self.get_user_participant() 

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

681 

682 self.archive.add(msg, user_part) 

683 

684 for user_full_jid in self.user_full_jids(): 

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

686 msg = copy(msg) 

687 msg.set_to(user_full_jid) 

688 

689 msg.send() 

690 

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

692 

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

694 self.__send_configuration_change((104,)) 

695 self._send_room_presence() 

696 

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

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

699 for to in tos: 

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

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

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

703 else: 

704 p["vcard_temp_update"]["photo"] = "" 

705 p.send() 

706 

707 @timeit 

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

709 user_full_jid = join_presence.get_from() 

710 requested_nickname = join_presence.get_to().resource 

711 client_resource = user_full_jid.resource 

712 

713 if client_resource in self.get_user_resources(): 

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

715 

716 if not requested_nickname or not client_resource: 

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

718 

719 self.add_user_resource(client_resource) 

720 

721 self.log.debug( 

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

723 client_resource, 

724 self.user_jid, 

725 self.legacy_id, 

726 requested_nickname, 

727 ) 

728 

729 user_nick = self.user_nick 

730 user_participant = None 

731 await self.__fill_participants() 

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

733 mav_until = await self.__get_mav() 

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

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

736 self.log.debug( 

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

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

739 mav_until, 

740 ) 

741 await self.__send_mav(user_full_jid, mav_until) 

742 else: 

743 mav_until = None 

744 async for participant in self.get_participants(): 

745 if participant.is_user: 

746 user_participant = participant 

747 continue 

748 participant.send_initial_presence(full_jid=user_full_jid) 

749 

750 if user_participant is None: 

751 user_participant = await self.get_user_participant() 

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

753 orm.add(self.stored) 

754 with orm.no_autoflush: 

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

756 if not user_participant.is_user: 

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

758 user_participant.is_user = True 

759 user_participant.send_initial_presence( 

760 user_full_jid, 

761 presence_id=join_presence["id"], 

762 nick_change=user_nick != requested_nickname, 

763 mav_until=mav_until, 

764 ) 

765 

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

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

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

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

770 try: 

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

772 except ValueError: 

773 since = None 

774 if seconds is not None: 

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

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

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

778 else: 

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

780 await self.__fill_history() 

781 await self.__old_school_history( 

782 user_full_jid, 

783 maxchars=maxchars, 

784 maxstanzas=maxstanzas, 

785 since=since, 

786 ) 

787 if self.HAS_SUBJECT: 

788 subject = self.subject or "" 

789 else: 

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

791 self.__get_subject_setter_participant().set_room_subject( 

792 subject, 

793 user_full_jid, 

794 self.subject_date, 

795 ) 

796 if t := self._set_avatar_task: 

797 await t 

798 self._send_room_presence(user_full_jid) 

799 

800 async def __get_mav(self) -> str: 

801 data = self.__get_mav_data() 

802 return self.__compute_mav_ver(data) 

803 

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

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

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

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

808 for part in self.stored.participants: 

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

810 continue 

811 if part.affiliation == "none": 

812 continue 

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

814 return data 

815 

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

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

818 

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

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

821 affs = [] 

822 for id_, aff in data: 

823 if aff == "none": 

824 continue 

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

826 affs.sort() 

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

828 

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

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

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

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

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

834 for part in self.stored.participants: 

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

836 continue 

837 item = MUCUserItem() 

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

839 item["affiliation"] = part.affiliation 

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

841 msg.send() 

842 

843 async def get_user_participant( 

844 self, 

845 *, 

846 fill_first: bool = False, 

847 store: bool = True, 

848 occupant_id: str | None = None, 

849 ) -> "LegacyParticipantType": 

850 """ 

851 Get the participant representing the gateway user 

852 

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

854 construction (optional) 

855 :return: 

856 """ 

857 p = await self.get_participant( 

858 self.user_nick, 

859 is_user=True, 

860 fill_first=fill_first, 

861 store=store, 

862 occupant_id=occupant_id, 

863 ) 

864 self.__store_participant(p) 

865 return p 

866 

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

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

869 return 

870 try: 

871 p.commit() 

872 except IntegrityError as e: 

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

874 self.stored = p.stored.room 

875 

876 @overload 

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

878 

879 @overload 

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

881 

882 @overload 

883 async def get_participant( 

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

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

886 

887 @overload 

888 async def get_participant( 

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

890 ) -> "LegacyParticipantType": ... 

891 

892 @overload 

893 async def get_participant( 

894 self, nickname: str, *, occupant_id: str 

895 ) -> "LegacyParticipantType": ... 

896 

897 @overload 

898 async def get_participant( 

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

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

901 

902 @overload 

903 async def get_participant( 

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

905 ) -> "LegacyParticipantType": ... 

906 

907 @overload 

908 async def get_participant( 

909 self, 

910 nickname: str, 

911 *, 

912 create: Literal[True], 

913 is_user: bool, 

914 fill_first: bool, 

915 store: bool, 

916 ) -> "LegacyParticipantType": ... 

917 

918 @overload 

919 async def get_participant( 

920 self, 

921 nickname: str, 

922 *, 

923 create: Literal[False], 

924 is_user: bool, 

925 fill_first: bool, 

926 store: bool, 

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

928 

929 @overload 

930 async def get_participant( 

931 self, 

932 nickname: str, 

933 *, 

934 create: bool, 

935 fill_first: bool, 

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

937 

938 @overload 

939 async def get_participant( 

940 self, 

941 nickname: str, 

942 *, 

943 is_user: Literal[True], 

944 fill_first: bool, 

945 store: bool, 

946 occupant_id: str | None = None, 

947 ) -> "LegacyParticipantType": ... 

948 

949 async def get_participant( 

950 self, 

951 nickname: str | None = None, 

952 *, 

953 create: bool = True, 

954 is_user: bool = False, 

955 fill_first: bool = False, 

956 store: bool = True, 

957 occupant_id: str | None = None, 

958 ) -> "LegacyParticipantType | None": 

959 """ 

960 Get a participant by their nickname. 

961 

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

963 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

964 

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

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

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

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

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

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

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

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

973 xep:`0421` 

974 :return: A participant of this room. 

975 """ 

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

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

978 if fill_first: 

979 await self.__fill_participants() 

980 if self.stored.id is not None: 

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

982 if occupant_id is not None: 

983 stored = ( 

984 orm.query(Participant) 

985 .filter( 

986 Participant.room == self.stored, 

987 Participant.occupant_id == occupant_id, 

988 ) 

989 .one_or_none() 

990 ) 

991 elif nickname is not None: 

992 stored = ( 

993 orm.query(Participant) 

994 .filter( 

995 Participant.room == self.stored, 

996 (Participant.nickname == nickname) 

997 | (Participant.resource == nickname), 

998 ) 

999 .one_or_none() 

1000 ) 

1001 else: 

1002 raise RuntimeError("NEVER") 

1003 if stored is not None: 

1004 if occupant_id and occupant_id != stored.occupant_id: 

1005 warnings.warn( 

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

1007 ) 

1008 part = self.participant_from_store(stored) 

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

1010 stored.nickname = nickname 

1011 orm.add(stored) 

1012 orm.commit() 

1013 return part 

1014 

1015 if not create: 

1016 return None 

1017 

1018 if occupant_id is None: 

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

1020 

1021 if nickname is None: 

1022 nickname = occupant_id 

1023 

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

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

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

1027 if is_user: 

1028 self.user_nick = nickname 

1029 

1030 p = self._participant_cls( 

1031 self, 

1032 Participant( 

1033 room=self.stored, 

1034 nickname=nickname or occupant_id, 

1035 is_user=is_user, 

1036 occupant_id=occupant_id, 

1037 ), 

1038 ) 

1039 if store: 

1040 self.__store_participant(p) 

1041 self.send_affiliation_change(p) 

1042 return p 

1043 

1044 def send_affiliation_change( 

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

1046 ) -> None: 

1047 # internal use by slidge 

1048 if not self.stored.participants_filled: 

1049 return 

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

1051 return 

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

1053 return 

1054 if part.contact is None: 

1055 return 

1056 if part.is_system: 

1057 return 

1058 if was == part.affiliation: 

1059 return 

1060 system_part = self.get_system_participant() 

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

1062 data = self.__get_mav_data() 

1063 since_data = [ 

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

1065 for id_, aff in data 

1066 ] 

1067 if part.affiliation == "none": 

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

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

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

1071 item = MUCUserItem() 

1072 item["affiliation"] = part.affiliation 

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

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

1075 system_part._send(msg) 

1076 

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

1078 """ 

1079 Get a pseudo-participant, representing the room itself 

1080 

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

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

1083 service 

1084 :return: 

1085 """ 

1086 return self._participant_cls( 

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

1088 ) 

1089 

1090 @overload 

1091 async def get_participant_by_contact( 

1092 self, c: "LegacyContact[Any]" 

1093 ) -> "LegacyParticipantType": ... 

1094 

1095 @overload 

1096 async def get_participant_by_contact( 

1097 self, c: "LegacyContact[Any]", *, occupant_id: str | None = None 

1098 ) -> "LegacyParticipantType": ... 

1099 

1100 @overload 

1101 async def get_participant_by_contact( 

1102 self, 

1103 c: "LegacyContact[Any]", 

1104 *, 

1105 create: Literal[False], 

1106 occupant_id: str | None, 

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

1108 

1109 @overload 

1110 async def get_participant_by_contact( 

1111 self, 

1112 c: "LegacyContact[Any]", 

1113 *, 

1114 create: Literal[True], 

1115 occupant_id: str | None, 

1116 ) -> "LegacyParticipantType": ... 

1117 

1118 async def get_participant_by_contact( 

1119 self, c: AnyContact, *, create: bool = True, occupant_id: str | None = None 

1120 ) -> "LegacyParticipantType | None": 

1121 """ 

1122 Get a non-anonymous participant. 

1123 

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

1125 that the Contact jid is associated to this participant 

1126 

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

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

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

1130 this participant. 

1131 :return: 

1132 """ 

1133 await self.session.contacts.ready 

1134 

1135 if self.stored.id is not None: 

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

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

1138 stored = ( 

1139 orm.query(Participant) 

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

1141 .one_or_none() 

1142 ) 

1143 if stored is None: 

1144 if occupant_id is not None: 

1145 stored = ( 

1146 orm.query(Participant) 

1147 .filter_by( 

1148 occupant_id=occupant_id, 

1149 room=self.stored, 

1150 contact_id=None, 

1151 ) 

1152 .one_or_none() 

1153 ) 

1154 if stored is not None: 

1155 self.log.debug( 

1156 "Updating the contact of a previously anonymous participant" 

1157 ) 

1158 stored.contact_id = c.stored.id 

1159 orm.add(stored) 

1160 orm.commit() 

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

1162 if not create: 

1163 return None 

1164 else: 

1165 if occupant_id and stored.occupant_id != occupant_id: 

1166 warnings.warn( 

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

1168 ) 

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

1170 

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

1172 

1173 if self.stored.id is None: 

1174 nick_available = True 

1175 else: 

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

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

1178 orm, self.stored.id, nickname 

1179 ) 

1180 

1181 if not nick_available: 

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

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

1184 p = self._participant_cls( 

1185 self, 

1186 Participant( 

1187 nickname=nickname, 

1188 room=self.stored, 

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

1190 ), 

1191 contact=c, 

1192 ) 

1193 

1194 self.__store_participant(p) 

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

1196 # during participants fill and history backfill we do not 

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

1198 # and role afterwards. 

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

1200 if ( 

1201 self.stored.participants_filled 

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

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

1204 ): 

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

1206 self.send_affiliation_change(p) 

1207 return p 

1208 

1209 @overload 

1210 async def get_participant_by_legacy_id( 

1211 self, legacy_id: LegacyUserIdType 

1212 ) -> "LegacyParticipantType": ... 

1213 

1214 @overload 

1215 async def get_participant_by_legacy_id( 

1216 self, 

1217 legacy_id: LegacyUserIdType, 

1218 *, 

1219 occupant_id: str | None, 

1220 create: Literal[True], 

1221 ) -> "LegacyParticipantType": ... 

1222 

1223 @overload 

1224 async def get_participant_by_legacy_id( 

1225 self, 

1226 legacy_id: LegacyUserIdType, 

1227 *, 

1228 occupant_id: str | None, 

1229 ) -> "LegacyParticipantType": ... 

1230 

1231 @overload 

1232 async def get_participant_by_legacy_id( 

1233 self, 

1234 legacy_id: LegacyUserIdType, 

1235 *, 

1236 occupant_id: str | None, 

1237 create: Literal[False], 

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

1239 

1240 async def get_participant_by_legacy_id( 

1241 self, 

1242 legacy_id: LegacyUserIdType, 

1243 *, 

1244 occupant_id: str | None = None, 

1245 create: bool = True, 

1246 ) -> "LegacyParticipantType": 

1247 try: 

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

1249 except ContactIsUser: 

1250 return await self.get_user_participant(occupant_id=occupant_id) 

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

1252 c, create=create, occupant_id=occupant_id 

1253 ) 

1254 

1255 def remove_participant( 

1256 self, 

1257 p: "LegacyParticipantType", 

1258 kick: bool = False, 

1259 ban: bool = False, 

1260 reason: str | None = None, 

1261 ) -> None: 

1262 """ 

1263 Call this when a participant leaves the room 

1264 

1265 :param p: The participant 

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

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

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

1269 """ 

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

1271 if kick and ban: 

1272 raise TypeError("Either kick or ban") 

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

1274 orm.delete(p.stored) 

1275 orm.commit() 

1276 if kick: 

1277 codes = {307} 

1278 elif ban: 

1279 codes = {301} 

1280 else: 

1281 codes = None 

1282 was = p.stored.affiliation 

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

1284 p.stored.role = "none" 

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

1286 self.send_affiliation_change(p, was) 

1287 if reason: 

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

1289 p._send(presence) 

1290 with self.orm() as orm: 

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

1292 

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

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

1295 stored = ( 

1296 orm.query(Participant) 

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

1298 .one_or_none() 

1299 ) 

1300 if stored is None: 

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

1302 return 

1303 p = self.participant_from_store(stored) 

1304 if p.nickname == old_nickname: 

1305 p.nickname = new_nickname 

1306 

1307 async def __old_school_history( 

1308 self, 

1309 full_jid: JID, 

1310 maxchars: int | None = None, 

1311 maxstanzas: int | None = None, 

1312 seconds: int | None = None, 

1313 since: datetime | None = None, 

1314 ) -> None: 

1315 """ 

1316 Old-style history join (internal slidge use) 

1317 

1318 :param full_jid: 

1319 :param maxchars: 

1320 :param maxstanzas: 

1321 :param seconds: 

1322 :param since: 

1323 :return: 

1324 """ 

1325 if since is None: 

1326 if seconds is None: 

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

1328 else: 

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

1330 else: 

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

1332 

1333 for h_msg in self.archive.get_all( 

1334 start_date=start_date, end_date=None, last_page_n=maxstanzas 

1335 ): 

1336 msg = h_msg.stanza_component_ns 

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

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

1339 msg.set_to(full_jid) 

1340 self.xmpp.send(msg, False) 

1341 

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

1343 await self.__fill_history() 

1344 

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

1346 

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

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

1349 

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

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

1352 

1353 sender = form_values.get("with") 

1354 

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

1356 

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

1358 try: 

1359 max_results = int(max_str) 

1360 except ValueError: 

1361 max_results = None 

1362 else: 

1363 max_results = None 

1364 

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

1366 after_id = after_id_rsm or after_id 

1367 

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

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

1370 last_page_n = max_results 

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

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

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

1374 if before_rsm is not True: 

1375 before_id = before_rsm 

1376 else: 

1377 last_page_n = None 

1378 

1379 first = None 

1380 last = None 

1381 count = 0 

1382 

1383 it = self.archive.get_all( 

1384 start_date, 

1385 end_date, 

1386 before_id, 

1387 after_id, 

1388 ids, 

1389 last_page_n, 

1390 sender, 

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

1392 ) 

1393 

1394 for history_msg in it: 

1395 last = xmpp_id = history_msg.id 

1396 if first is None: 

1397 first = xmpp_id 

1398 

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

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

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

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

1403 

1404 wrapper_msg.send() 

1405 count += 1 

1406 

1407 if max_results and count == max_results: 

1408 break 

1409 

1410 if max_results: 

1411 try: 

1412 next(it) 

1413 except StopIteration: 

1414 complete = True 

1415 else: 

1416 complete = False 

1417 else: 

1418 complete = True 

1419 

1420 reply = iq.reply() 

1421 if not self.STABLE_ARCHIVE: 

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

1423 if complete: 

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

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

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

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

1428 reply.send() 

1429 

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

1431 await self.__fill_history() 

1432 await self.archive.send_metadata(iq) 

1433 

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

1435 """ 

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

1437 

1438 :param r: The resource to kick 

1439 """ 

1440 pto = JID(self.user_jid) 

1441 pto.resource = r 

1442 p = self.xmpp.make_presence( 

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

1444 ) 

1445 p["type"] = "unavailable" 

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

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

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

1449 p.send() 

1450 

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

1452 item = Item() 

1453 item["id"] = self.jid 

1454 

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

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

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

1458 

1459 try: 

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

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

1462 return None 

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

1464 # (slixmpp annoying magic) 

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

1466 item["id"] = self.jid 

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

1468 except IqTimeout: 

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

1470 return None 

1471 except IqError as exc: 

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

1473 return None 

1474 except PermissionError: 

1475 warnings.warn( 

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

1477 ) 

1478 return None 

1479 

1480 async def add_to_bookmarks( 

1481 self, 

1482 auto_join: bool = True, 

1483 preserve: bool = True, 

1484 pin: bool | None = None, 

1485 notify: WhenLiteral | None = None, 

1486 ) -> None: 

1487 """ 

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

1489 

1490 This requires that slidge has the IQ privileged set correctly 

1491 on the XMPP server 

1492 

1493 :param auto_join: whether XMPP clients should automatically join 

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

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

1496 join if they are online. 

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

1498 set by the user outside slidge 

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

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

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

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

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

1504 """ 

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

1506 

1507 new = Item() 

1508 new["id"] = self.jid 

1509 new["conference"]["nick"] = self.user_nick 

1510 

1511 if existing is None: 

1512 change = True 

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

1514 else: 

1515 change = False 

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

1517 

1518 existing_extensions = ( 

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

1520 ) 

1521 

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

1523 if existing_extensions: 

1524 assert existing is not None 

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

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

1527 continue 

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

1529 continue 

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

1531 

1532 if pin is not None: 

1533 if existing_extensions: 

1534 assert existing is not None 

1535 existing_pin = ( 

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

1537 "pinned", check=True 

1538 ) 

1539 is not None 

1540 ) 

1541 if existing_pin != pin: 

1542 change = True 

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

1544 

1545 if notify is not None: 

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

1547 if existing_extensions: 

1548 assert existing is not None 

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

1550 "notify", check=True 

1551 ) 

1552 if existing_notify is None: 

1553 change = True 

1554 else: 

1555 if existing_notify.get_config() != notify: 

1556 change = True 

1557 for el in existing_notify: 

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

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

1560 

1561 if change: 

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

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

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

1565 

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

1567 

1568 try: 

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

1570 except PermissionError: 

1571 warnings.warn( 

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

1573 ) 

1574 # fallback by forcing invitation 

1575 bookmark_add_fail = True 

1576 except IqError as e: 

1577 warnings.warn( 

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

1579 ) 

1580 # fallback by forcing invitation 

1581 bookmark_add_fail = True 

1582 else: 

1583 bookmark_add_fail = False 

1584 else: 

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

1586 return 

1587 

1588 if bookmark_add_fail: 

1589 self.session.send_gateway_invite( 

1590 self, 

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

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

1593 "Contact your administrator.", 

1594 ) 

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

1596 "always_invite_when_adding_bookmarks", True 

1597 ): 

1598 self.session.send_gateway_invite( 

1599 self, 

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

1601 ) 

1602 

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

1604 """ 

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

1606 client. 

1607 

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

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

1610 updated on the XMPP side. 

1611 

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

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

1614 room update event. 

1615 

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

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

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

1619 correct. 

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

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

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

1623 """ 

1624 raise NotImplementedError 

1625 

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

1627 

1628 async def on_set_affiliation( 

1629 self, 

1630 contact: AnyContact, 

1631 affiliation: MucAffiliation, 

1632 reason: str | None, 

1633 nickname: str | None, 

1634 ) -> None: 

1635 """ 

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

1637 for this group. 

1638 

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

1640 

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

1642 :param affiliation: The new affiliation 

1643 :param reason: A reason for this affiliation change 

1644 :param nickname: 

1645 """ 

1646 raise NotImplementedError 

1647 

1648 async def on_kick(self, contact: AnyContact, reason: str | None) -> None: 

1649 """ 

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

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

1652 

1653 :param contact: Contact to be kicked 

1654 :param reason: A reason for this kick 

1655 """ 

1656 raise NotImplementedError 

1657 

1658 async def on_set_config( 

1659 self, 

1660 name: str | None, 

1661 description: str | None, 

1662 ) -> None: 

1663 """ 

1664 Triggered when the user requests changing the room configuration. 

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

1666 

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

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

1669 

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

1671 be ``None``. 

1672 

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

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

1675 """ 

1676 raise NotImplementedError 

1677 

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

1679 """ 

1680 Triggered when the user requests room destruction. 

1681 

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

1683 """ 

1684 raise NotImplementedError 

1685 

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

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

1688 await self.__fill_participants() 

1689 orm.add(self.stored) 

1690 participants = { 

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

1692 } 

1693 

1694 if len(participants) == 0: 

1695 return [] 

1696 

1697 result = [] 

1698 for match in re.finditer( 

1699 "|".join( 

1700 sorted( 

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

1702 key=lambda nick: len(nick), 

1703 reverse=True, 

1704 ) 

1705 ), 

1706 text, 

1707 ): 

1708 span = match.span() 

1709 nick = match.group() 

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

1711 continue 

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

1713 participant = self.participant_from_store( 

1714 stored=participants[nick], 

1715 ) 

1716 if contact := participant.contact: 

1717 result.append( 

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

1719 ) 

1720 return result 

1721 

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

1723 """ 

1724 Triggered when the user requests changing the room subject. 

1725 

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

1727 instance. 

1728 

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

1730 """ 

1731 raise NotImplementedError 

1732 

1733 async def on_set_thread_subject( 

1734 self, thread: LegacyThreadType, subject: str 

1735 ) -> None: 

1736 """ 

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

1738 

1739 :param thread: Legacy identifier of the thread 

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

1741 """ 

1742 raise NotImplementedError 

1743 

1744 @property 

1745 def participants_filled(self) -> bool: 

1746 return self.stored.participants_filled 

1747 

1748 def get_archived_messages( 

1749 self, msg_id: LegacyMessageType | str 

1750 ) -> Iterator[HistoryMessage]: 

1751 """ 

1752 Query the slidge archive for messages sent in this group 

1753 

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

1755 or an XMPP ID. 

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

1757 because of multi-attachment messages. 

1758 """ 

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

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

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

1762 ): 

1763 yield HistoryMessage(stored.stanza) 

1764 

1765 

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

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

1768 sub.attrib["id"] = origin_id 

1769 msg.xml.append(sub) 

1770 

1771 

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

1773 try: 

1774 return int(x) 

1775 except ValueError: 

1776 return None 

1777 

1778 

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

1780 if x is None: 

1781 return False 

1782 else: 

1783 return x == 0 

1784 

1785 

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

1787 if date is None: 

1788 return None 

1789 try: 

1790 return str_to_datetime(date) 

1791 except ValueError: 

1792 return None 

1793 

1794 

1795def bookmarks_form() -> Form: 

1796 form = Form() 

1797 form["type"] = "submit" 

1798 form.add_field( 

1799 "FORM_TYPE", 

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

1801 ftype="hidden", 

1802 ) 

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

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

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

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

1807 return form 

1808 

1809 

1810_BOOKMARKS_OPTIONS = bookmarks_form() 

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

1812 

1813log = logging.getLogger(__name__)