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

912 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-09 03:59 +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, Generic, Literal, TypeAlias, 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.disco import ChatterDiscoMixin 

34from ..core.mixins.recipient import RecipientMixin 

35from ..db.models import Participant, Room 

36from ..util.archive_msg import HistoryMessage 

37from ..util.jid_escaping import unescape_node 

38from ..util.types import ( 

39 AnyParticipant, 

40 AnySession, 

41 HoleBound, 

42 LegacyParticipantType, 

43 Mention, 

44 MucAffiliation, 

45 MucType, 

46) 

47from ..util.util import SubclassableOnce, timeit 

48from .archive import MessageArchive 

49from .participant import LegacyParticipant, escape_nickname 

50 

51if TYPE_CHECKING: 

52 from ..command.base import MUCCommand 

53 from ..db.avatar import CachedAvatar 

54 

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

56 

57SubjectSetterType: TypeAlias = "str | None | LegacyContact | AnyParticipant" 

58 

59 

60class LegacyMUC( 

61 Generic[LegacyParticipantType], 

62 AvatarMixin, 

63 ChatterDiscoMixin, 

64 RecipientMixin, 

65 SubclassableOnce, 

66): 

67 """ 

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

69 

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

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

72 """ 

73 

74 max_history_fetch = 100 

75 

76 is_group: Literal[True] = True 

77 

78 DISCO_TYPE = "text" 

79 DISCO_CATEGORY = "conference" 

80 

81 STABLE_ARCHIVE = False 

82 """ 

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

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

85 across restarts. 

86 

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

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

89 

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

91 """ 

92 

93 """ 

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

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

96 """ 

97 

98 HAS_DESCRIPTION = True 

99 """ 

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

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

102 room configuration form. 

103 """ 

104 

105 HAS_SUBJECT = True 

106 """ 

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

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

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

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

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

112 tries to set the room subject. 

113 """ 

114 

115 archive: MessageArchive 

116 session: AnySession 

117 

118 stored: Room 

119 

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

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

122 

123 _participant_cls: type[LegacyParticipantType] 

124 

125 is_participant: Literal[False] = False 

126 

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

128 self.session = session 

129 self.xmpp = session.xmpp 

130 self.stored = stored 

131 self._set_logger() 

132 super().__init__() 

133 

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

135 

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

137 """ 

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

139 

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

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

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

143 

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

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

146 MUC will be marked as read. 

147 

148 :param horizon_xmpp_id: The latest message 

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

150 """ 

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

152 assert self.stored.id is not None 

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

154 orm, self.stored.id, horizon_xmpp_id 

155 ) 

156 orm.commit() 

157 return ids 

158 

159 def participant_from_store( 

160 self, stored: Participant, contact: LegacyContact | None = None 

161 ) -> LegacyParticipantType: 

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

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

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

165 

166 @property 

167 def jid(self) -> JID: 

168 return self.stored.jid 

169 

170 @jid.setter 

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

172 # FIXME: without this, mypy yields 

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

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

175 raise RuntimeError 

176 

177 @property 

178 def legacy_id(self) -> str: 

179 return self.stored.legacy_id 

180 

181 @property 

182 def space_legacy_id(self) -> str: 

183 return self.stored.space.legacy_id 

184 

185 @space_legacy_id.setter 

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

187 if ( 

188 self.stored 

189 and self.stored.space 

190 and self.stored.space.legacy_id == legacy_id 

191 ): 

192 return 

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

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

195 self.stored.space = space 

196 if self._updating_info: 

197 with orm.no_autoflush: 

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

199 return 

200 orm.add(self.stored) 

201 orm.commit() 

202 

203 def orm( 

204 self, 

205 **kwargs: Any, # noqa:ANN401 

206 ) -> OrmSession: 

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

208 

209 @property 

210 def type(self) -> MucType: 

211 return self.stored.muc_type 

212 

213 @type.setter 

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

215 if self.type == type_: 

216 return 

217 self.update_stored_attribute(muc_type=type_) 

218 

219 @property 

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

221 return self.stored.n_participants 

222 

223 @n_participants.setter 

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

225 if self.stored.n_participants == n_participants: 

226 return 

227 self.update_stored_attribute(n_participants=n_participants) 

228 

229 @property 

230 def user_jid(self) -> JID: 

231 return self.session.user_jid 

232 

233 def _set_logger(self) -> None: 

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

235 

236 def __repr__(self) -> str: 

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

238 

239 @property 

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

241 if self.stored.subject_date is None: 

242 return None 

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

244 

245 @subject_date.setter 

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

247 if self.subject_date == when: 

248 return 

249 self.update_stored_attribute(subject_date=when) 

250 

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

252 part = self.get_system_participant() 

253 part.send_configuration_change(codes) 

254 

255 @property 

256 def user_nick(self) -> str: 

257 return ( 

258 self.stored.user_nick 

259 or self.session.bookmarks.user_nick 

260 or self.user_jid.node 

261 ) 

262 

263 @user_nick.setter 

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

265 if nick == self.user_nick: 

266 return 

267 self.update_stored_attribute(user_nick=nick) 

268 

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

270 stored_set = self.get_user_resources() 

271 if resource in stored_set: 

272 return 

273 stored_set.add(resource) 

274 self.update_stored_attribute( 

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

276 ) 

277 

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

279 stored_str = self.stored.user_resources 

280 if stored_str is None: 

281 return set() 

282 return set(json.loads(stored_str)) 

283 

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

285 stored_set = self.get_user_resources() 

286 if resource not in stored_set: 

287 return 

288 stored_set.remove(resource) 

289 self.update_stored_attribute( 

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

291 ) 

292 

293 @asynccontextmanager 

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

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

296 yield 

297 

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

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

300 

301 async def __fill_participants(self) -> None: 

302 if self.participants_filled: 

303 return 

304 

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

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

307 orm.add(self.stored) 

308 with orm.no_autoflush: 

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

310 if self.participants_filled: 

311 return 

312 parts: list[Participant] = [] 

313 resources = set[str]() 

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

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

316 user_found = False 

317 async for participant in self.fill_participants(): 

318 if participant.is_user: 

319 user_found = True 

320 if participant.stored.resource in resources: 

321 self.log.debug( 

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

323 participant.stored.resource, 

324 ) 

325 continue 

326 parts.append(participant.stored) 

327 resources.add(participant.stored.resource) 

328 

329 if not user_found: 

330 participant = await self.get_user_participant() 

331 if participant.stored.resource in resources: 

332 for p in parts: 

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

334 p.is_user = True 

335 else: 

336 parts.append(participant.stored) 

337 resources.add(participant.stored.resource) 

338 

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

340 orm.add(self.stored) 

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

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

343 # and the participant_filled attribute. 

344 with orm.no_autoflush: 

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

346 for part in parts: 

347 orm.merge(part) 

348 self.stored.participants_filled = True 

349 orm.commit() 

350 

351 async def get_participants( 

352 self, affiliation: MucAffiliation | None = None 

353 ) -> AsyncIterator[LegacyParticipantType]: 

354 await self.__fill_participants() 

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

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

357 db_participants = self.stored.participants 

358 for db_participant in db_participants: 

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

360 continue 

361 yield self.participant_from_store(db_participant) 

362 

363 async def __fill_history(self) -> None: 

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

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

366 orm.add(self.stored) 

367 with orm.no_autoflush: 

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

369 if self.stored.history_filled: 

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

371 return 

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

373 try: 

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

375 if before is not None: 

376 before = before._replace(id=before.id) 

377 if after is not None: 

378 after = after._replace(id=after.id) 

379 await self.backfill(before, after) 

380 except NotImplementedError: 

381 return 

382 except Exception as e: 

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

384 

385 self.stored.history_filled = True 

386 self.commit() 

387 

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

389 return self.name 

390 

391 @property 

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

393 return self.stored.name 

394 

395 @name.setter 

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

397 if self.name == n: 

398 return 

399 self.update_stored_attribute(name=n) 

400 self._set_logger() 

401 self.__send_configuration_change((104,)) 

402 

403 @property 

404 def description(self) -> str: 

405 return self.stored.description or "" 

406 

407 @description.setter 

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

409 if self.description == d: 

410 return 

411 self.update_stored_attribute(description=d) 

412 self.__send_configuration_change((104,)) 

413 

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

415 pto = p.get_to() 

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

417 return 

418 

419 pfrom = p.get_from() 

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

421 return 

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

423 if pto.resource != self.user_nick: 

424 self.log.debug( 

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

426 ) 

427 self.remove_user_resource(resource) 

428 else: 

429 self.log.debug( 

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

431 ) 

432 

433 async def update_info(self) -> None: 

434 """ 

435 Fetch information about this group from the legacy network 

436 

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

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

439 of participants etc. 

440 

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

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

443 is no change, you should not call 

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

445 attempt to modify 

446 the :attr:.avatar property. 

447 """ 

448 raise NotImplementedError 

449 

450 async def backfill( 

451 self, 

452 after: HoleBound | None = None, 

453 before: HoleBound | None = None, 

454 ) -> None: 

455 """ 

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

457 

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

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

460 run for a given group. 

461 

462 :param after: Fetch messages after this one. 

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

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

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

466 the user registered. 

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

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

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

470 :param before: Fetch messages before this one. 

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

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

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

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

475 """ 

476 raise NotImplementedError 

477 

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

479 """ 

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

481 

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

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

484 before yielding them. 

485 """ 

486 return 

487 yield 

488 

489 @property 

490 def subject(self) -> str: 

491 return self.stored.subject or "" 

492 

493 @subject.setter 

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

495 if s == self.subject: 

496 return 

497 

498 self.update_stored_attribute(subject=s) 

499 self.__get_subject_setter_participant().set_room_subject( 

500 s, None, self.subject_date, False 

501 ) 

502 

503 @property 

504 def is_anonymous(self) -> bool: 

505 return self.type == MucType.CHANNEL 

506 

507 @property 

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

509 return self.stored.subject_setter 

510 

511 @subject_setter.setter 

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

513 if isinstance(subject_setter, LegacyContact): 

514 subject_setter = subject_setter.name 

515 elif isinstance(subject_setter, LegacyParticipant): 

516 subject_setter = subject_setter.nickname 

517 

518 if subject_setter == self.subject_setter: 

519 return 

520 assert isinstance(subject_setter, str | None) 

521 self.update_stored_attribute(subject_setter=subject_setter) 

522 

523 def __get_subject_setter_participant(self) -> AnyParticipant: 

524 if self.subject_setter is None: 

525 return self.get_system_participant() 

526 return self._participant_cls( 

527 self, 

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

529 ) 

530 

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

532 features = [ 

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

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

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

536 "urn:xmpp:mam:2", 

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

538 "urn:xmpp:sid:0", 

539 "muc_persistent", 

540 "vcard-temp", 

541 "urn:xmpp:ping", 

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

543 "jabber:iq:register", 

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

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

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

547 ] 

548 if self.type == MucType.GROUP: 

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

550 elif self.type == MucType.CHANNEL: 

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

552 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

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

554 

555 try: # oh boy, this sucks 

556 has_space = self.stored.space is not None 

557 except DetachedInstanceError: 

558 self.refresh() 

559 has_space = self.stored.space is not None 

560 

561 if has_space: 

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

563 return features 

564 

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

566 is_group = self.type == MucType.GROUP 

567 

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

569 

570 form.add_field( 

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

572 ) 

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

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

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

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

577 

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

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

580 n = orm.scalar( 

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

582 room_id=self.stored.id 

583 ) 

584 ) 

585 else: 

586 n = self.n_participants 

587 if n is not None: 

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

589 

590 if d := self.description: 

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

592 

593 if s := self.subject: 

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

595 

596 if name := self.name: 

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

598 

599 if self._set_avatar_task is not None: 

600 await self._set_avatar_task 

601 avatar = self.get_avatar() 

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

603 form.add_field( 

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

605 ) 

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

607 

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

609 form.add_field( 

610 "muc#roomconfig_whois", 

611 "list-single", 

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

613 ) 

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

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

616 

617 r = [form] 

618 

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

620 r.append(reaction_form) 

621 

622 if self.stored.space is not None: 

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

624 self.stored.space.legacy_id 

625 ) 

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

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

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

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

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

631 

632 return r 

633 

634 def shutdown(self) -> None: 

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

636 for user_full_jid in self.user_full_jids(): 

637 presence = self.xmpp.make_presence( 

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

639 ) 

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

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

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

643 presence.send() 

644 

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

646 for r in self.get_user_resources(): 

647 j = JID(self.user_jid) 

648 j.resource = r 

649 yield j 

650 

651 @property 

652 def user_muc_jid(self) -> JID: 

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

654 return user_muc_jid 

655 

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

657 msg.set_from(self.user_muc_jid) 

658 if legacy_msg_id: 

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

660 else: 

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

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

663 

664 user_part = await self.get_user_participant() 

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

666 

667 self.archive.add(msg, user_part) 

668 

669 for user_full_jid in self.user_full_jids(): 

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

671 msg = copy(msg) 

672 msg.set_to(user_full_jid) 

673 

674 msg.send() 

675 

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

677 

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

679 self.__send_configuration_change((104,)) 

680 self._send_room_presence() 

681 

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

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

684 for to in tos: 

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

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

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

688 else: 

689 p["vcard_temp_update"]["photo"] = "" 

690 p.send() 

691 

692 @timeit 

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

694 user_full_jid = join_presence.get_from() 

695 requested_nickname = join_presence.get_to().resource 

696 client_resource = user_full_jid.resource 

697 

698 if client_resource in self.get_user_resources(): 

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

700 

701 if not requested_nickname or not client_resource: 

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

703 

704 self.add_user_resource(client_resource) 

705 

706 self.log.debug( 

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

708 client_resource, 

709 self.user_jid, 

710 self.legacy_id, 

711 requested_nickname, 

712 ) 

713 

714 user_nick = self.user_nick 

715 user_participant = None 

716 await self.__fill_participants() 

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

718 mav_until = await self.__get_mav() 

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

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

721 self.log.debug( 

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

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

724 mav_until, 

725 ) 

726 await self.__send_mav(user_full_jid, mav_until) 

727 else: 

728 mav_until = None 

729 async for participant in self.get_participants(): 

730 if participant.is_user: 

731 user_participant = participant 

732 continue 

733 participant.send_initial_presence(full_jid=user_full_jid) 

734 

735 if user_participant is None: 

736 user_participant = await self.get_user_participant() 

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

738 orm.add(self.stored) 

739 with orm.no_autoflush: 

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

741 if not user_participant.is_user: 

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

743 user_participant.is_user = True 

744 user_participant.send_initial_presence( 

745 user_full_jid, 

746 presence_id=join_presence["id"], 

747 nick_change=user_nick != requested_nickname, 

748 mav_until=mav_until, 

749 ) 

750 

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

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

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

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

755 try: 

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

757 except ValueError: 

758 since = None 

759 if seconds is not None: 

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

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

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

763 else: 

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

765 await self.__fill_history() 

766 await self.__old_school_history( 

767 user_full_jid, 

768 maxchars=maxchars, 

769 maxstanzas=maxstanzas, 

770 since=since, 

771 ) 

772 if self.HAS_SUBJECT: 

773 subject = self.subject or "" 

774 else: 

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

776 self.__get_subject_setter_participant().set_room_subject( 

777 subject, 

778 user_full_jid, 

779 self.subject_date, 

780 ) 

781 if t := self._set_avatar_task: 

782 await t 

783 self._send_room_presence(user_full_jid) 

784 

785 async def __get_mav(self) -> str: 

786 data = self.__get_mav_data() 

787 return self.__compute_mav_ver(data) 

788 

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

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

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

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

793 for part in self.stored.participants: 

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

795 continue 

796 if part.affiliation == "none": 

797 continue 

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

799 return data 

800 

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

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

803 

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

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

806 affs = [] 

807 for id_, aff in data: 

808 if aff == "none": 

809 continue 

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

811 affs.sort() 

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

813 

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

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

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

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

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

819 for part in self.stored.participants: 

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

821 continue 

822 item = MUCUserItem() 

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

824 item["affiliation"] = part.affiliation 

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

826 msg.send() 

827 

828 async def get_user_participant( 

829 self, 

830 *, 

831 fill_first: bool = False, 

832 store: bool = True, 

833 occupant_id: str | None = None, 

834 ) -> "LegacyParticipantType": 

835 """ 

836 Get the participant representing the gateway user 

837 

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

839 construction (optional) 

840 :return: 

841 """ 

842 p = await self.get_participant( 

843 self.user_nick, 

844 is_user=True, 

845 fill_first=fill_first, 

846 store=store, 

847 occupant_id=occupant_id, 

848 ) 

849 self.__store_participant(p) 

850 return p 

851 

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

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

854 return 

855 try: 

856 p.commit() 

857 except IntegrityError as e: 

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

859 self.stored = p.stored.room 

860 

861 @overload 

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

863 

864 @overload 

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

866 

867 @overload 

868 async def get_participant( 

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

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

871 

872 @overload 

873 async def get_participant( 

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

875 ) -> "LegacyParticipantType": ... 

876 

877 @overload 

878 async def get_participant( 

879 self, nickname: str, *, occupant_id: str 

880 ) -> "LegacyParticipantType": ... 

881 

882 @overload 

883 async def get_participant( 

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

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

886 

887 @overload 

888 async def get_participant( 

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

890 ) -> "LegacyParticipantType": ... 

891 

892 @overload 

893 async def get_participant( 

894 self, 

895 nickname: str, 

896 *, 

897 create: Literal[True], 

898 is_user: bool, 

899 fill_first: bool, 

900 store: bool, 

901 ) -> "LegacyParticipantType": ... 

902 

903 @overload 

904 async def get_participant( 

905 self, 

906 nickname: str, 

907 *, 

908 create: Literal[False], 

909 is_user: bool, 

910 fill_first: bool, 

911 store: bool, 

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

913 

914 @overload 

915 async def get_participant( 

916 self, 

917 nickname: str, 

918 *, 

919 create: bool, 

920 fill_first: bool, 

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

922 

923 @overload 

924 async def get_participant( 

925 self, 

926 nickname: str, 

927 *, 

928 is_user: Literal[True], 

929 fill_first: bool, 

930 store: bool, 

931 occupant_id: str | None = None, 

932 ) -> "LegacyParticipantType": ... 

933 

934 async def get_participant( 

935 self, 

936 nickname: str | None = None, 

937 *, 

938 create: bool = True, 

939 is_user: bool = False, 

940 fill_first: bool = False, 

941 store: bool = True, 

942 occupant_id: str | None = None, 

943 ) -> "LegacyParticipantType | None": 

944 """ 

945 Get a participant by their nickname. 

946 

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

948 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

949 

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

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

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

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

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

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

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

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

958 xep:`0421` 

959 :return: A participant of this room. 

960 """ 

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

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

963 if fill_first: 

964 await self.__fill_participants() 

965 if self.stored.id is not None: 

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

967 if occupant_id is not None: 

968 stored = ( 

969 orm.query(Participant) 

970 .filter( 

971 Participant.room == self.stored, 

972 Participant.occupant_id == occupant_id, 

973 ) 

974 .one_or_none() 

975 ) 

976 elif nickname is not None: 

977 stored = ( 

978 orm.query(Participant) 

979 .filter( 

980 Participant.room == self.stored, 

981 (Participant.nickname == nickname) 

982 | (Participant.resource == nickname), 

983 ) 

984 .one_or_none() 

985 ) 

986 else: 

987 raise RuntimeError("NEVER") 

988 if stored is not None: 

989 if occupant_id and occupant_id != stored.occupant_id: 

990 warnings.warn( 

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

992 ) 

993 part = self.participant_from_store(stored) 

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

995 stored.nickname = nickname 

996 orm.add(stored) 

997 orm.commit() 

998 return part 

999 

1000 if not create: 

1001 return None 

1002 

1003 if occupant_id is None: 

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

1005 

1006 if nickname is None: 

1007 nickname = occupant_id 

1008 

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

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

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

1012 if is_user: 

1013 self.user_nick = nickname 

1014 

1015 p = self._participant_cls( 

1016 self, 

1017 Participant( 

1018 room=self.stored, 

1019 nickname=nickname or occupant_id, 

1020 is_user=is_user, 

1021 occupant_id=occupant_id, 

1022 ), 

1023 ) 

1024 if store: 

1025 self.__store_participant(p) 

1026 self.send_affiliation_change(p) 

1027 return p 

1028 

1029 def send_affiliation_change( 

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

1031 ) -> None: 

1032 # internal use by slidge 

1033 if not self.stored.participants_filled: 

1034 return 

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

1036 return 

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

1038 return 

1039 if part.contact is None: 

1040 return 

1041 if part.is_system: 

1042 return 

1043 if was == part.affiliation: 

1044 return 

1045 system_part = self.get_system_participant() 

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

1047 data = self.__get_mav_data() 

1048 since_data = [ 

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

1050 for id_, aff in data 

1051 ] 

1052 if part.affiliation == "none": 

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

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

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

1056 item = MUCUserItem() 

1057 item["affiliation"] = part.affiliation 

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

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

1060 system_part._send(msg) 

1061 

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

1063 """ 

1064 Get a pseudo-participant, representing the room itself 

1065 

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

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

1068 service 

1069 :return: 

1070 """ 

1071 return self._participant_cls( 

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

1073 ) 

1074 

1075 @overload 

1076 async def get_participant_by_contact( 

1077 self, c: "LegacyContact" 

1078 ) -> "LegacyParticipantType": ... 

1079 

1080 @overload 

1081 async def get_participant_by_contact( 

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

1083 ) -> "LegacyParticipantType": ... 

1084 

1085 @overload 

1086 async def get_participant_by_contact( 

1087 self, 

1088 c: "LegacyContact", 

1089 *, 

1090 create: Literal[False], 

1091 occupant_id: str | None, 

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

1093 

1094 @overload 

1095 async def get_participant_by_contact( 

1096 self, 

1097 c: "LegacyContact", 

1098 *, 

1099 create: Literal[True], 

1100 occupant_id: str | None, 

1101 ) -> "LegacyParticipantType": ... 

1102 

1103 async def get_participant_by_contact( 

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

1105 ) -> "LegacyParticipantType | None": 

1106 """ 

1107 Get a non-anonymous participant. 

1108 

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

1110 that the Contact jid is associated to this participant 

1111 

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

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

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

1115 this participant. 

1116 :return: 

1117 """ 

1118 await self.session.contacts.ready 

1119 

1120 if self.stored.id is not None: 

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

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

1123 stored = ( 

1124 orm.query(Participant) 

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

1126 .one_or_none() 

1127 ) 

1128 if stored is None: 

1129 if occupant_id is not None: 

1130 stored = ( 

1131 orm.query(Participant) 

1132 .filter_by( 

1133 occupant_id=occupant_id, 

1134 room=self.stored, 

1135 contact_id=None, 

1136 ) 

1137 .one_or_none() 

1138 ) 

1139 if stored is not None: 

1140 self.log.debug( 

1141 "Updating the contact of a previously anonymous participant" 

1142 ) 

1143 stored.contact_id = c.stored.id 

1144 orm.add(stored) 

1145 orm.commit() 

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

1147 if not create: 

1148 return None 

1149 else: 

1150 if occupant_id and stored.occupant_id != occupant_id: 

1151 warnings.warn( 

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

1153 ) 

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

1155 

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

1157 

1158 if self.stored.id is None: 

1159 nick_available = True 

1160 else: 

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

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

1163 orm, self.stored.id, nickname 

1164 ) 

1165 

1166 if not nick_available: 

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

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

1169 p = self._participant_cls( 

1170 self, 

1171 Participant( 

1172 nickname=nickname, 

1173 room=self.stored, 

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

1175 ), 

1176 contact=c, 

1177 ) 

1178 

1179 self.__store_participant(p) 

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

1181 # during participants fill and history backfill we do not 

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

1183 # and role afterwards. 

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

1185 if ( 

1186 self.stored.participants_filled 

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

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

1189 ): 

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

1191 self.send_affiliation_change(p) 

1192 return p 

1193 

1194 @overload 

1195 async def get_participant_by_legacy_id( 

1196 self, legacy_id: str 

1197 ) -> "LegacyParticipantType": ... 

1198 

1199 @overload 

1200 async def get_participant_by_legacy_id( 

1201 self, 

1202 legacy_id: str, 

1203 *, 

1204 occupant_id: str | None, 

1205 create: Literal[True], 

1206 ) -> "LegacyParticipantType": ... 

1207 

1208 @overload 

1209 async def get_participant_by_legacy_id( 

1210 self, 

1211 legacy_id: str, 

1212 *, 

1213 occupant_id: str | None, 

1214 ) -> "LegacyParticipantType": ... 

1215 

1216 @overload 

1217 async def get_participant_by_legacy_id( 

1218 self, 

1219 legacy_id: str, 

1220 *, 

1221 occupant_id: str | None, 

1222 create: Literal[False], 

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

1224 

1225 async def get_participant_by_legacy_id( 

1226 self, 

1227 legacy_id: str, 

1228 *, 

1229 occupant_id: str | None = None, 

1230 create: bool = True, 

1231 ) -> "LegacyParticipantType": 

1232 try: 

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

1234 except ContactIsUser: 

1235 return await self.get_user_participant(occupant_id=occupant_id) 

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

1237 c, create=create, occupant_id=occupant_id 

1238 ) 

1239 

1240 def remove_participant( 

1241 self, 

1242 p: "LegacyParticipantType", 

1243 kick: bool = False, 

1244 ban: bool = False, 

1245 reason: str | None = None, 

1246 ) -> None: 

1247 """ 

1248 Call this when a participant leaves the room 

1249 

1250 :param p: The participant 

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

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

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

1254 """ 

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

1256 if kick and ban: 

1257 raise TypeError("Either kick or ban") 

1258 if kick: 

1259 codes = {307} 

1260 elif ban: 

1261 codes = {301} 

1262 else: 

1263 codes = None 

1264 was = p.stored.affiliation 

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

1266 p.stored.role = "none" 

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

1268 self.send_affiliation_change(p, was) 

1269 if reason: 

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

1271 p._send(presence) 

1272 with self.orm() as orm: 

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

1274 orm.commit() 

1275 

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

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

1278 stored = ( 

1279 orm.query(Participant) 

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

1281 .one_or_none() 

1282 ) 

1283 if stored is None: 

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

1285 return 

1286 p = self.participant_from_store(stored) 

1287 if p.nickname == old_nickname: 

1288 p.nickname = new_nickname 

1289 

1290 async def __old_school_history( 

1291 self, 

1292 full_jid: JID, 

1293 maxchars: int | None = None, 

1294 maxstanzas: int | None = None, 

1295 seconds: int | None = None, 

1296 since: datetime | None = None, 

1297 ) -> None: 

1298 """ 

1299 Old-style history join (internal slidge use) 

1300 

1301 :param full_jid: 

1302 :param maxchars: 

1303 :param maxstanzas: 

1304 :param seconds: 

1305 :param since: 

1306 :return: 

1307 """ 

1308 if since is None: 

1309 if seconds is None: 

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

1311 else: 

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

1313 else: 

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

1315 

1316 for h_msg in self.archive.get_all( 

1317 start_date=start_date, end_date=None, last_page_n=maxstanzas 

1318 ): 

1319 msg = h_msg.stanza_component_ns 

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

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

1322 msg.set_to(full_jid) 

1323 self.xmpp.send(msg, False) 

1324 

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

1326 await self.__fill_history() 

1327 

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

1329 

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

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

1332 

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

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

1335 

1336 sender = form_values.get("with") 

1337 

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

1339 

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

1341 try: 

1342 max_results = int(max_str) 

1343 except ValueError: 

1344 max_results = None 

1345 else: 

1346 max_results = None 

1347 

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

1349 after_id = after_id_rsm or after_id 

1350 

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

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

1353 last_page_n = max_results 

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

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

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

1357 if before_rsm is not True: 

1358 before_id = before_rsm 

1359 else: 

1360 last_page_n = None 

1361 

1362 first = None 

1363 last = None 

1364 count = 0 

1365 

1366 it = self.archive.get_all( 

1367 start_date, 

1368 end_date, 

1369 before_id, 

1370 after_id, 

1371 ids, 

1372 last_page_n, 

1373 sender, 

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

1375 ) 

1376 

1377 for history_msg in it: 

1378 last = xmpp_id = history_msg.id 

1379 if first is None: 

1380 first = xmpp_id 

1381 

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

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

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

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

1386 

1387 wrapper_msg.send() 

1388 count += 1 

1389 

1390 if max_results and count == max_results: 

1391 break 

1392 

1393 if max_results: 

1394 try: 

1395 next(it) 

1396 except StopIteration: 

1397 complete = True 

1398 else: 

1399 complete = False 

1400 else: 

1401 complete = True 

1402 

1403 reply = iq.reply() 

1404 if not self.STABLE_ARCHIVE: 

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

1406 if complete: 

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

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

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

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

1411 reply.send() 

1412 

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

1414 await self.__fill_history() 

1415 await self.archive.send_metadata(iq) 

1416 

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

1418 """ 

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

1420 

1421 :param r: The resource to kick 

1422 """ 

1423 pto = JID(self.user_jid) 

1424 pto.resource = r 

1425 p = self.xmpp.make_presence( 

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

1427 ) 

1428 p["type"] = "unavailable" 

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

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

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

1432 p.send() 

1433 

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

1435 item = Item() 

1436 item["id"] = self.jid 

1437 

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

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

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

1441 

1442 try: 

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

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

1445 return None 

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

1447 # (slixmpp annoying magic) 

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

1449 item["id"] = self.jid 

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

1451 except IqTimeout: 

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

1453 return None 

1454 except IqError as exc: 

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

1456 return None 

1457 except PermissionError: 

1458 warnings.warn( 

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

1460 ) 

1461 return None 

1462 

1463 async def add_to_bookmarks( 

1464 self, 

1465 auto_join: bool = True, 

1466 preserve: bool = True, 

1467 pin: bool | None = None, 

1468 notify: WhenLiteral | None = None, 

1469 ) -> None: 

1470 """ 

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

1472 

1473 This requires that slidge has the IQ privileged set correctly 

1474 on the XMPP server 

1475 

1476 :param auto_join: whether XMPP clients should automatically join 

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

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

1479 join if they are online. 

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

1481 set by the user outside slidge 

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

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

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

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

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

1487 """ 

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

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

1490 

1491 new = Item() 

1492 new["id"] = self.jid 

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

1494 

1495 if existing is None: 

1496 change = True 

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

1498 else: 

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

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

1501 

1502 existing_extensions = ( 

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

1504 ) 

1505 

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

1507 if existing_extensions: 

1508 assert existing is not None 

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

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

1511 continue 

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

1513 continue 

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

1515 

1516 if pin is not None: 

1517 if existing_extensions: 

1518 assert existing is not None 

1519 existing_pin = ( 

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

1521 "pinned", check=True 

1522 ) 

1523 is not None 

1524 ) 

1525 if existing_pin != pin: 

1526 change = True 

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

1528 

1529 if notify is not None: 

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

1531 if existing_extensions: 

1532 assert existing is not None 

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

1534 "notify", check=True 

1535 ) 

1536 if existing_notify is None: 

1537 change = True 

1538 else: 

1539 if existing_notify.get_config() != notify: 

1540 change = True 

1541 for el in existing_notify: 

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

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

1544 

1545 if change: 

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

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

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

1549 

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

1551 

1552 try: 

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

1554 except PermissionError: 

1555 warnings.warn( 

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

1557 ) 

1558 # fallback by forcing invitation 

1559 bookmark_add_fail = True 

1560 except IqError as e: 

1561 warnings.warn( 

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

1563 ) 

1564 # fallback by forcing invitation 

1565 bookmark_add_fail = True 

1566 else: 

1567 bookmark_add_fail = False 

1568 else: 

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

1570 return 

1571 

1572 if bookmark_add_fail: 

1573 self.session.send_gateway_invite( 

1574 self, 

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

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

1577 "Contact your administrator.", 

1578 ) 

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

1580 "always_invite_when_adding_bookmarks", True 

1581 ): 

1582 self.session.send_gateway_invite( 

1583 self, 

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

1585 ) 

1586 

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

1588 """ 

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

1590 client. 

1591 

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

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

1594 updated on the XMPP side. 

1595 

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

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

1598 room update event. 

1599 

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

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

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

1603 correct. 

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

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

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

1607 """ 

1608 raise NotImplementedError 

1609 

1610 async def on_set_config( 

1611 self, 

1612 name: str | None, 

1613 description: str | None, 

1614 ) -> None: 

1615 """ 

1616 Triggered when the user requests changing the room configuration. 

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

1618 

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

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

1621 

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

1623 be ``None``. 

1624 

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

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

1627 """ 

1628 raise NotImplementedError 

1629 

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

1631 """ 

1632 Triggered when the user requests room destruction. 

1633 

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

1635 """ 

1636 raise NotImplementedError 

1637 

1638 async def parse_mentions(self, text: str | None) -> tuple[Mention, ...]: 

1639 if not text: 

1640 return () 

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

1642 await self.__fill_participants() 

1643 orm.add(self.stored) 

1644 participants = { 

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

1646 } 

1647 

1648 if len(participants) == 0: 

1649 return () 

1650 

1651 result = [] 

1652 for match in re.finditer( 

1653 "|".join( 

1654 sorted( 

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

1656 key=lambda nick: len(nick), 

1657 reverse=True, 

1658 ) 

1659 ), 

1660 text, 

1661 ): 

1662 span = match.span() 

1663 nick = match.group() 

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

1665 continue 

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

1667 participant = self.participant_from_store( 

1668 stored=participants[nick], 

1669 ) 

1670 if contact := participant.contact: 

1671 result.append( 

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

1673 ) 

1674 return tuple(result) 

1675 

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

1677 """ 

1678 Triggered when the user requests changing the room subject. 

1679 

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

1681 instance. 

1682 

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

1684 """ 

1685 raise NotImplementedError 

1686 

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

1688 """ 

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

1690 

1691 :param thread: Legacy identifier of the thread 

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

1693 """ 

1694 raise NotImplementedError 

1695 

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

1697 """ 

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

1699 a MUC using :xep:`0425`. 

1700 

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

1702 XMPPError with a human-readable message. 

1703 

1704 NB: the legacy module is responsible for calling 

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

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

1707 moderation message from the MUC automatically. 

1708 

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

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

1711 user-moderator. 

1712 """ 

1713 raise NotImplementedError 

1714 

1715 async def on_leave(self) -> None: 

1716 """ 

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

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

1719 

1720 This should be interpreted as definitely leaving the group. 

1721 """ 

1722 raise NotImplementedError 

1723 

1724 @property 

1725 def participants_filled(self) -> bool: 

1726 return self.stored.participants_filled 

1727 

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

1729 """ 

1730 Query the slidge archive for messages sent in this group 

1731 

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

1733 or an XMPP ID. 

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

1735 because of multi-attachment messages. 

1736 """ 

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

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

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

1740 ): 

1741 yield HistoryMessage(stored.stanza) 

1742 

1743 

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

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

1746 sub.attrib["id"] = origin_id 

1747 msg.xml.append(sub) 

1748 

1749 

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

1751 try: 

1752 return int(x) 

1753 except ValueError: 

1754 return None 

1755 

1756 

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

1758 if x is None: 

1759 return False 

1760 else: 

1761 return x == 0 

1762 

1763 

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

1765 if date is None: 

1766 return None 

1767 try: 

1768 return str_to_datetime(date) 

1769 except ValueError: 

1770 return None 

1771 

1772 

1773def bookmarks_form() -> Form: 

1774 form = Form() 

1775 form["type"] = "submit" 

1776 form.add_field( 

1777 "FORM_TYPE", 

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

1779 ftype="hidden", 

1780 ) 

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

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

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

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

1785 return form 

1786 

1787 

1788_BOOKMARKS_OPTIONS = bookmarks_form() 

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

1790 

1791log = logging.getLogger(__name__)