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

909 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-13 04:38 +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 with self.orm(expire_on_commit=False) as orm: 

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

189 self.stored.space = space 

190 if self._updating_info: 

191 with orm.no_autoflush: 

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

193 return 

194 orm.add(self.stored) 

195 orm.commit() 

196 

197 def orm( 

198 self, 

199 **kwargs: Any, # noqa:ANN401 

200 ) -> OrmSession: 

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

202 

203 @property 

204 def type(self) -> MucType: 

205 return self.stored.muc_type 

206 

207 @type.setter 

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

209 if self.type == type_: 

210 return 

211 self.update_stored_attribute(muc_type=type_) 

212 

213 @property 

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

215 return self.stored.n_participants 

216 

217 @n_participants.setter 

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

219 if self.stored.n_participants == n_participants: 

220 return 

221 self.update_stored_attribute(n_participants=n_participants) 

222 

223 @property 

224 def user_jid(self) -> JID: 

225 return self.session.user_jid 

226 

227 def _set_logger(self) -> None: 

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

229 

230 def __repr__(self) -> str: 

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

232 

233 @property 

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

235 if self.stored.subject_date is None: 

236 return None 

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

238 

239 @subject_date.setter 

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

241 if self.subject_date == when: 

242 return 

243 self.update_stored_attribute(subject_date=when) 

244 

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

246 part = self.get_system_participant() 

247 part.send_configuration_change(codes) 

248 

249 @property 

250 def user_nick(self) -> str: 

251 return ( 

252 self.stored.user_nick 

253 or self.session.bookmarks.user_nick 

254 or self.user_jid.node 

255 ) 

256 

257 @user_nick.setter 

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

259 if nick == self.user_nick: 

260 return 

261 self.update_stored_attribute(user_nick=nick) 

262 

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

264 stored_set = self.get_user_resources() 

265 if resource in stored_set: 

266 return 

267 stored_set.add(resource) 

268 self.update_stored_attribute( 

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

270 ) 

271 

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

273 stored_str = self.stored.user_resources 

274 if stored_str is None: 

275 return set() 

276 return set(json.loads(stored_str)) 

277 

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

279 stored_set = self.get_user_resources() 

280 if resource not in stored_set: 

281 return 

282 stored_set.remove(resource) 

283 self.update_stored_attribute( 

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

285 ) 

286 

287 @asynccontextmanager 

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

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

290 yield 

291 

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

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

294 

295 async def __fill_participants(self) -> None: 

296 if self.participants_filled: 

297 return 

298 

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

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

301 orm.add(self.stored) 

302 with orm.no_autoflush: 

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

304 if self.participants_filled: 

305 return 

306 parts: list[Participant] = [] 

307 resources = set[str]() 

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

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

310 user_found = False 

311 async for participant in self.fill_participants(): 

312 if participant.is_user: 

313 user_found = True 

314 if participant.stored.resource in resources: 

315 self.log.debug( 

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

317 participant.stored.resource, 

318 ) 

319 continue 

320 parts.append(participant.stored) 

321 resources.add(participant.stored.resource) 

322 

323 if not user_found: 

324 participant = await self.get_user_participant() 

325 if participant.stored.resource in resources: 

326 for p in parts: 

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

328 p.is_user = True 

329 else: 

330 parts.append(participant.stored) 

331 resources.add(participant.stored.resource) 

332 

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

334 orm.add(self.stored) 

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

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

337 # and the participant_filled attribute. 

338 with orm.no_autoflush: 

339 orm.refresh(self.stored) 

340 for part in parts: 

341 orm.merge(part) 

342 self.stored.participants_filled = True 

343 orm.commit() 

344 

345 async def get_participants( 

346 self, affiliation: MucAffiliation | None = None 

347 ) -> AsyncIterator[LegacyParticipantType]: 

348 await self.__fill_participants() 

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

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

351 db_participants = self.stored.participants 

352 for db_participant in db_participants: 

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

354 continue 

355 yield self.participant_from_store(db_participant) 

356 

357 async def __fill_history(self) -> None: 

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

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

360 orm.add(self.stored) 

361 with orm.no_autoflush: 

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

363 if self.stored.history_filled: 

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

365 return 

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

367 try: 

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

369 if before is not None: 

370 before = before._replace(id=before.id) 

371 if after is not None: 

372 after = after._replace(id=after.id) 

373 await self.backfill(before, after) 

374 except NotImplementedError: 

375 return 

376 except Exception as e: 

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

378 

379 self.stored.history_filled = True 

380 self.commit() 

381 

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

383 return self.name 

384 

385 @property 

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

387 return self.stored.name 

388 

389 @name.setter 

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

391 if self.name == n: 

392 return 

393 self.update_stored_attribute(name=n) 

394 self._set_logger() 

395 self.__send_configuration_change((104,)) 

396 

397 @property 

398 def description(self) -> str: 

399 return self.stored.description or "" 

400 

401 @description.setter 

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

403 if self.description == d: 

404 return 

405 self.update_stored_attribute(description=d) 

406 self.__send_configuration_change((104,)) 

407 

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

409 pto = p.get_to() 

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

411 return 

412 

413 pfrom = p.get_from() 

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

415 return 

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

417 if pto.resource != self.user_nick: 

418 self.log.debug( 

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

420 ) 

421 self.remove_user_resource(resource) 

422 else: 

423 self.log.debug( 

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

425 ) 

426 

427 async def update_info(self) -> None: 

428 """ 

429 Fetch information about this group from the legacy network 

430 

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

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

433 of participants etc. 

434 

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

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

437 is no change, you should not call 

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

439 attempt to modify 

440 the :attr:.avatar property. 

441 """ 

442 raise NotImplementedError 

443 

444 async def backfill( 

445 self, 

446 after: HoleBound | None = None, 

447 before: HoleBound | None = None, 

448 ) -> None: 

449 """ 

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

451 

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

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

454 run for a given group. 

455 

456 :param after: Fetch messages after this one. 

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

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

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

460 the user registered. 

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

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

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

464 :param before: Fetch messages before this one. 

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

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

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

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

469 """ 

470 raise NotImplementedError 

471 

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

473 """ 

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

475 

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

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

478 before yielding them. 

479 """ 

480 return 

481 yield 

482 

483 @property 

484 def subject(self) -> str: 

485 return self.stored.subject or "" 

486 

487 @subject.setter 

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

489 if s == self.subject: 

490 return 

491 

492 self.update_stored_attribute(subject=s) 

493 self.__get_subject_setter_participant().set_room_subject( 

494 s, None, self.subject_date, False 

495 ) 

496 

497 @property 

498 def is_anonymous(self) -> bool: 

499 return self.type == MucType.CHANNEL 

500 

501 @property 

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

503 return self.stored.subject_setter 

504 

505 @subject_setter.setter 

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

507 if isinstance(subject_setter, LegacyContact): 

508 subject_setter = subject_setter.name 

509 elif isinstance(subject_setter, LegacyParticipant): 

510 subject_setter = subject_setter.nickname 

511 

512 if subject_setter == self.subject_setter: 

513 return 

514 assert isinstance(subject_setter, str | None) 

515 self.update_stored_attribute(subject_setter=subject_setter) 

516 

517 def __get_subject_setter_participant(self) -> AnyParticipant: 

518 if self.subject_setter is None: 

519 return self.get_system_participant() 

520 return self._participant_cls( 

521 self, 

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

523 ) 

524 

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

526 features = [ 

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

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

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

530 "urn:xmpp:mam:2", 

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

532 "urn:xmpp:sid:0", 

533 "muc_persistent", 

534 "vcard-temp", 

535 "urn:xmpp:ping", 

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

537 "jabber:iq:register", 

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

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

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

541 ] 

542 if self.type == MucType.GROUP: 

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

544 elif self.type == MucType.CHANNEL: 

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

546 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

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

548 

549 try: # oh boy, this sucks 

550 has_space = self.stored.space is not None 

551 except DetachedInstanceError: 

552 self.refresh() 

553 has_space = self.stored.space is not None 

554 

555 if has_space: 

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

557 return features 

558 

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

560 is_group = self.type == MucType.GROUP 

561 

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

563 

564 form.add_field( 

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

566 ) 

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

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

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

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

571 

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

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

574 n = orm.scalar( 

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

576 room_id=self.stored.id 

577 ) 

578 ) 

579 else: 

580 n = self.n_participants 

581 if n is not None: 

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

583 

584 if d := self.description: 

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

586 

587 if s := self.subject: 

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

589 

590 if name := self.name: 

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

592 

593 if self._set_avatar_task is not None: 

594 await self._set_avatar_task 

595 avatar = self.get_avatar() 

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

597 form.add_field( 

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

599 ) 

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

601 

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

603 form.add_field( 

604 "muc#roomconfig_whois", 

605 "list-single", 

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

607 ) 

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

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

610 

611 r = [form] 

612 

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

614 r.append(reaction_form) 

615 

616 if self.stored.space is not None: 

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

618 self.stored.space.legacy_id 

619 ) 

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

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

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

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

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

625 

626 return r 

627 

628 def shutdown(self) -> None: 

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

630 for user_full_jid in self.user_full_jids(): 

631 presence = self.xmpp.make_presence( 

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

633 ) 

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

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

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

637 presence.send() 

638 

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

640 for r in self.get_user_resources(): 

641 j = JID(self.user_jid) 

642 j.resource = r 

643 yield j 

644 

645 @property 

646 def user_muc_jid(self) -> JID: 

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

648 return user_muc_jid 

649 

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

651 msg.set_from(self.user_muc_jid) 

652 if legacy_msg_id: 

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

654 else: 

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

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

657 

658 user_part = await self.get_user_participant() 

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

660 

661 self.archive.add(msg, user_part) 

662 

663 for user_full_jid in self.user_full_jids(): 

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

665 msg = copy(msg) 

666 msg.set_to(user_full_jid) 

667 

668 msg.send() 

669 

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

671 

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

673 self.__send_configuration_change((104,)) 

674 self._send_room_presence() 

675 

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

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

678 for to in tos: 

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

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

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

682 else: 

683 p["vcard_temp_update"]["photo"] = "" 

684 p.send() 

685 

686 @timeit 

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

688 user_full_jid = join_presence.get_from() 

689 requested_nickname = join_presence.get_to().resource 

690 client_resource = user_full_jid.resource 

691 

692 if client_resource in self.get_user_resources(): 

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

694 

695 if not requested_nickname or not client_resource: 

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

697 

698 self.add_user_resource(client_resource) 

699 

700 self.log.debug( 

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

702 client_resource, 

703 self.user_jid, 

704 self.legacy_id, 

705 requested_nickname, 

706 ) 

707 

708 user_nick = self.user_nick 

709 user_participant = None 

710 await self.__fill_participants() 

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

712 mav_until = await self.__get_mav() 

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

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

715 self.log.debug( 

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

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

718 mav_until, 

719 ) 

720 await self.__send_mav(user_full_jid, mav_until) 

721 else: 

722 mav_until = None 

723 async for participant in self.get_participants(): 

724 if participant.is_user: 

725 user_participant = participant 

726 continue 

727 participant.send_initial_presence(full_jid=user_full_jid) 

728 

729 if user_participant is None: 

730 user_participant = await self.get_user_participant() 

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

732 orm.add(self.stored) 

733 with orm.no_autoflush: 

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

735 if not user_participant.is_user: 

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

737 user_participant.is_user = True 

738 user_participant.send_initial_presence( 

739 user_full_jid, 

740 presence_id=join_presence["id"], 

741 nick_change=user_nick != requested_nickname, 

742 mav_until=mav_until, 

743 ) 

744 

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

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

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

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

749 try: 

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

751 except ValueError: 

752 since = None 

753 if seconds is not None: 

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

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

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

757 else: 

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

759 await self.__fill_history() 

760 await self.__old_school_history( 

761 user_full_jid, 

762 maxchars=maxchars, 

763 maxstanzas=maxstanzas, 

764 since=since, 

765 ) 

766 if self.HAS_SUBJECT: 

767 subject = self.subject or "" 

768 else: 

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

770 self.__get_subject_setter_participant().set_room_subject( 

771 subject, 

772 user_full_jid, 

773 self.subject_date, 

774 ) 

775 if t := self._set_avatar_task: 

776 await t 

777 self._send_room_presence(user_full_jid) 

778 

779 async def __get_mav(self) -> str: 

780 data = self.__get_mav_data() 

781 return self.__compute_mav_ver(data) 

782 

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

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

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

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

787 for part in self.stored.participants: 

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

789 continue 

790 if part.affiliation == "none": 

791 continue 

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

793 return data 

794 

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

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

797 

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

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

800 affs = [] 

801 for id_, aff in data: 

802 if aff == "none": 

803 continue 

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

805 affs.sort() 

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

807 

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

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

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

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

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

813 for part in self.stored.participants: 

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

815 continue 

816 item = MUCUserItem() 

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

818 item["affiliation"] = part.affiliation 

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

820 msg.send() 

821 

822 async def get_user_participant( 

823 self, 

824 *, 

825 fill_first: bool = False, 

826 store: bool = True, 

827 occupant_id: str | None = None, 

828 ) -> "LegacyParticipantType": 

829 """ 

830 Get the participant representing the gateway user 

831 

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

833 construction (optional) 

834 :return: 

835 """ 

836 p = await self.get_participant( 

837 self.user_nick, 

838 is_user=True, 

839 fill_first=fill_first, 

840 store=store, 

841 occupant_id=occupant_id, 

842 ) 

843 self.__store_participant(p) 

844 return p 

845 

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

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

848 return 

849 try: 

850 p.commit() 

851 except IntegrityError as e: 

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

853 self.stored = p.stored.room 

854 

855 @overload 

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

857 

858 @overload 

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

860 

861 @overload 

862 async def get_participant( 

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

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

865 

866 @overload 

867 async def get_participant( 

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

869 ) -> "LegacyParticipantType": ... 

870 

871 @overload 

872 async def get_participant( 

873 self, nickname: str, *, occupant_id: str 

874 ) -> "LegacyParticipantType": ... 

875 

876 @overload 

877 async def get_participant( 

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

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

880 

881 @overload 

882 async def get_participant( 

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

884 ) -> "LegacyParticipantType": ... 

885 

886 @overload 

887 async def get_participant( 

888 self, 

889 nickname: str, 

890 *, 

891 create: Literal[True], 

892 is_user: bool, 

893 fill_first: bool, 

894 store: bool, 

895 ) -> "LegacyParticipantType": ... 

896 

897 @overload 

898 async def get_participant( 

899 self, 

900 nickname: str, 

901 *, 

902 create: Literal[False], 

903 is_user: bool, 

904 fill_first: bool, 

905 store: bool, 

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

907 

908 @overload 

909 async def get_participant( 

910 self, 

911 nickname: str, 

912 *, 

913 create: bool, 

914 fill_first: bool, 

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

916 

917 @overload 

918 async def get_participant( 

919 self, 

920 nickname: str, 

921 *, 

922 is_user: Literal[True], 

923 fill_first: bool, 

924 store: bool, 

925 occupant_id: str | None = None, 

926 ) -> "LegacyParticipantType": ... 

927 

928 async def get_participant( 

929 self, 

930 nickname: str | None = None, 

931 *, 

932 create: bool = True, 

933 is_user: bool = False, 

934 fill_first: bool = False, 

935 store: bool = True, 

936 occupant_id: str | None = None, 

937 ) -> "LegacyParticipantType | None": 

938 """ 

939 Get a participant by their nickname. 

940 

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

942 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

943 

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

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

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

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

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

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

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

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

952 xep:`0421` 

953 :return: A participant of this room. 

954 """ 

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

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

957 if fill_first: 

958 await self.__fill_participants() 

959 if self.stored.id is not None: 

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

961 if occupant_id is not None: 

962 stored = ( 

963 orm.query(Participant) 

964 .filter( 

965 Participant.room == self.stored, 

966 Participant.occupant_id == occupant_id, 

967 ) 

968 .one_or_none() 

969 ) 

970 elif nickname is not None: 

971 stored = ( 

972 orm.query(Participant) 

973 .filter( 

974 Participant.room == self.stored, 

975 (Participant.nickname == nickname) 

976 | (Participant.resource == nickname), 

977 ) 

978 .one_or_none() 

979 ) 

980 else: 

981 raise RuntimeError("NEVER") 

982 if stored is not None: 

983 if occupant_id and occupant_id != stored.occupant_id: 

984 warnings.warn( 

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

986 ) 

987 part = self.participant_from_store(stored) 

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

989 stored.nickname = nickname 

990 orm.add(stored) 

991 orm.commit() 

992 return part 

993 

994 if not create: 

995 return None 

996 

997 if occupant_id is None: 

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

999 

1000 if nickname is None: 

1001 nickname = occupant_id 

1002 

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

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

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

1006 if is_user: 

1007 self.user_nick = nickname 

1008 

1009 p = self._participant_cls( 

1010 self, 

1011 Participant( 

1012 room=self.stored, 

1013 nickname=nickname or occupant_id, 

1014 is_user=is_user, 

1015 occupant_id=occupant_id, 

1016 ), 

1017 ) 

1018 if store: 

1019 self.__store_participant(p) 

1020 self.send_affiliation_change(p) 

1021 return p 

1022 

1023 def send_affiliation_change( 

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

1025 ) -> None: 

1026 # internal use by slidge 

1027 if not self.stored.participants_filled: 

1028 return 

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

1030 return 

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

1032 return 

1033 if part.contact is None: 

1034 return 

1035 if part.is_system: 

1036 return 

1037 if was == part.affiliation: 

1038 return 

1039 system_part = self.get_system_participant() 

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

1041 data = self.__get_mav_data() 

1042 since_data = [ 

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

1044 for id_, aff in data 

1045 ] 

1046 if part.affiliation == "none": 

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

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

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

1050 item = MUCUserItem() 

1051 item["affiliation"] = part.affiliation 

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

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

1054 system_part._send(msg) 

1055 

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

1057 """ 

1058 Get a pseudo-participant, representing the room itself 

1059 

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

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

1062 service 

1063 :return: 

1064 """ 

1065 return self._participant_cls( 

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

1067 ) 

1068 

1069 @overload 

1070 async def get_participant_by_contact( 

1071 self, c: "LegacyContact" 

1072 ) -> "LegacyParticipantType": ... 

1073 

1074 @overload 

1075 async def get_participant_by_contact( 

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

1077 ) -> "LegacyParticipantType": ... 

1078 

1079 @overload 

1080 async def get_participant_by_contact( 

1081 self, 

1082 c: "LegacyContact", 

1083 *, 

1084 create: Literal[False], 

1085 occupant_id: str | None, 

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

1087 

1088 @overload 

1089 async def get_participant_by_contact( 

1090 self, 

1091 c: "LegacyContact", 

1092 *, 

1093 create: Literal[True], 

1094 occupant_id: str | None, 

1095 ) -> "LegacyParticipantType": ... 

1096 

1097 async def get_participant_by_contact( 

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

1099 ) -> "LegacyParticipantType | None": 

1100 """ 

1101 Get a non-anonymous participant. 

1102 

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

1104 that the Contact jid is associated to this participant 

1105 

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

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

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

1109 this participant. 

1110 :return: 

1111 """ 

1112 await self.session.contacts.ready 

1113 

1114 if self.stored.id is not None: 

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

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

1117 stored = ( 

1118 orm.query(Participant) 

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

1120 .one_or_none() 

1121 ) 

1122 if stored is None: 

1123 if occupant_id is not None: 

1124 stored = ( 

1125 orm.query(Participant) 

1126 .filter_by( 

1127 occupant_id=occupant_id, 

1128 room=self.stored, 

1129 contact_id=None, 

1130 ) 

1131 .one_or_none() 

1132 ) 

1133 if stored is not None: 

1134 self.log.debug( 

1135 "Updating the contact of a previously anonymous participant" 

1136 ) 

1137 stored.contact_id = c.stored.id 

1138 orm.add(stored) 

1139 orm.commit() 

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

1141 if not create: 

1142 return None 

1143 else: 

1144 if occupant_id and stored.occupant_id != occupant_id: 

1145 warnings.warn( 

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

1147 ) 

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

1149 

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

1151 

1152 if self.stored.id is None: 

1153 nick_available = True 

1154 else: 

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

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

1157 orm, self.stored.id, nickname 

1158 ) 

1159 

1160 if not nick_available: 

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

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

1163 p = self._participant_cls( 

1164 self, 

1165 Participant( 

1166 nickname=nickname, 

1167 room=self.stored, 

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

1169 ), 

1170 contact=c, 

1171 ) 

1172 

1173 self.__store_participant(p) 

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

1175 # during participants fill and history backfill we do not 

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

1177 # and role afterwards. 

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

1179 if ( 

1180 self.stored.participants_filled 

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

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

1183 ): 

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

1185 self.send_affiliation_change(p) 

1186 return p 

1187 

1188 @overload 

1189 async def get_participant_by_legacy_id( 

1190 self, legacy_id: str 

1191 ) -> "LegacyParticipantType": ... 

1192 

1193 @overload 

1194 async def get_participant_by_legacy_id( 

1195 self, 

1196 legacy_id: str, 

1197 *, 

1198 occupant_id: str | None, 

1199 create: Literal[True], 

1200 ) -> "LegacyParticipantType": ... 

1201 

1202 @overload 

1203 async def get_participant_by_legacy_id( 

1204 self, 

1205 legacy_id: str, 

1206 *, 

1207 occupant_id: str | None, 

1208 ) -> "LegacyParticipantType": ... 

1209 

1210 @overload 

1211 async def get_participant_by_legacy_id( 

1212 self, 

1213 legacy_id: str, 

1214 *, 

1215 occupant_id: str | None, 

1216 create: Literal[False], 

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

1218 

1219 async def get_participant_by_legacy_id( 

1220 self, 

1221 legacy_id: str, 

1222 *, 

1223 occupant_id: str | None = None, 

1224 create: bool = True, 

1225 ) -> "LegacyParticipantType": 

1226 try: 

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

1228 except ContactIsUser: 

1229 return await self.get_user_participant(occupant_id=occupant_id) 

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

1231 c, create=create, occupant_id=occupant_id 

1232 ) 

1233 

1234 def remove_participant( 

1235 self, 

1236 p: "LegacyParticipantType", 

1237 kick: bool = False, 

1238 ban: bool = False, 

1239 reason: str | None = None, 

1240 ) -> None: 

1241 """ 

1242 Call this when a participant leaves the room 

1243 

1244 :param p: The participant 

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

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

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

1248 """ 

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

1250 if kick and ban: 

1251 raise TypeError("Either kick or ban") 

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

1253 orm.delete(p.stored) 

1254 orm.commit() 

1255 if kick: 

1256 codes = {307} 

1257 elif ban: 

1258 codes = {301} 

1259 else: 

1260 codes = None 

1261 was = p.stored.affiliation 

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

1263 p.stored.role = "none" 

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

1265 self.send_affiliation_change(p, was) 

1266 if reason: 

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

1268 p._send(presence) 

1269 with self.orm() as orm: 

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

1271 

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

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

1274 stored = ( 

1275 orm.query(Participant) 

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

1277 .one_or_none() 

1278 ) 

1279 if stored is None: 

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

1281 return 

1282 p = self.participant_from_store(stored) 

1283 if p.nickname == old_nickname: 

1284 p.nickname = new_nickname 

1285 

1286 async def __old_school_history( 

1287 self, 

1288 full_jid: JID, 

1289 maxchars: int | None = None, 

1290 maxstanzas: int | None = None, 

1291 seconds: int | None = None, 

1292 since: datetime | None = None, 

1293 ) -> None: 

1294 """ 

1295 Old-style history join (internal slidge use) 

1296 

1297 :param full_jid: 

1298 :param maxchars: 

1299 :param maxstanzas: 

1300 :param seconds: 

1301 :param since: 

1302 :return: 

1303 """ 

1304 if since is None: 

1305 if seconds is None: 

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

1307 else: 

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

1309 else: 

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

1311 

1312 for h_msg in self.archive.get_all( 

1313 start_date=start_date, end_date=None, last_page_n=maxstanzas 

1314 ): 

1315 msg = h_msg.stanza_component_ns 

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

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

1318 msg.set_to(full_jid) 

1319 self.xmpp.send(msg, False) 

1320 

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

1322 await self.__fill_history() 

1323 

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

1325 

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

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

1328 

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

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

1331 

1332 sender = form_values.get("with") 

1333 

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

1335 

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

1337 try: 

1338 max_results = int(max_str) 

1339 except ValueError: 

1340 max_results = None 

1341 else: 

1342 max_results = None 

1343 

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

1345 after_id = after_id_rsm or after_id 

1346 

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

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

1349 last_page_n = max_results 

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

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

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

1353 if before_rsm is not True: 

1354 before_id = before_rsm 

1355 else: 

1356 last_page_n = None 

1357 

1358 first = None 

1359 last = None 

1360 count = 0 

1361 

1362 it = self.archive.get_all( 

1363 start_date, 

1364 end_date, 

1365 before_id, 

1366 after_id, 

1367 ids, 

1368 last_page_n, 

1369 sender, 

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

1371 ) 

1372 

1373 for history_msg in it: 

1374 last = xmpp_id = history_msg.id 

1375 if first is None: 

1376 first = xmpp_id 

1377 

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

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

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

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

1382 

1383 wrapper_msg.send() 

1384 count += 1 

1385 

1386 if max_results and count == max_results: 

1387 break 

1388 

1389 if max_results: 

1390 try: 

1391 next(it) 

1392 except StopIteration: 

1393 complete = True 

1394 else: 

1395 complete = False 

1396 else: 

1397 complete = True 

1398 

1399 reply = iq.reply() 

1400 if not self.STABLE_ARCHIVE: 

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

1402 if complete: 

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

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

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

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

1407 reply.send() 

1408 

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

1410 await self.__fill_history() 

1411 await self.archive.send_metadata(iq) 

1412 

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

1414 """ 

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

1416 

1417 :param r: The resource to kick 

1418 """ 

1419 pto = JID(self.user_jid) 

1420 pto.resource = r 

1421 p = self.xmpp.make_presence( 

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

1423 ) 

1424 p["type"] = "unavailable" 

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

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

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

1428 p.send() 

1429 

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

1431 item = Item() 

1432 item["id"] = self.jid 

1433 

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

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

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

1437 

1438 try: 

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

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

1441 return None 

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

1443 # (slixmpp annoying magic) 

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

1445 item["id"] = self.jid 

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

1447 except IqTimeout: 

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

1449 return None 

1450 except IqError as exc: 

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

1452 return None 

1453 except PermissionError: 

1454 warnings.warn( 

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

1456 ) 

1457 return None 

1458 

1459 async def add_to_bookmarks( 

1460 self, 

1461 auto_join: bool = True, 

1462 preserve: bool = True, 

1463 pin: bool | None = None, 

1464 notify: WhenLiteral | None = None, 

1465 ) -> None: 

1466 """ 

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

1468 

1469 This requires that slidge has the IQ privileged set correctly 

1470 on the XMPP server 

1471 

1472 :param auto_join: whether XMPP clients should automatically join 

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

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

1475 join if they are online. 

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

1477 set by the user outside slidge 

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

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

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

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

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

1483 """ 

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

1485 

1486 new = Item() 

1487 new["id"] = self.jid 

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

1489 

1490 if existing is None: 

1491 change = True 

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

1493 else: 

1494 change = False 

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

1496 

1497 existing_extensions = ( 

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

1499 ) 

1500 

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

1502 if existing_extensions: 

1503 assert existing is not None 

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

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

1506 continue 

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

1508 continue 

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

1510 

1511 if pin is not None: 

1512 if existing_extensions: 

1513 assert existing is not None 

1514 existing_pin = ( 

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

1516 "pinned", check=True 

1517 ) 

1518 is not None 

1519 ) 

1520 if existing_pin != pin: 

1521 change = True 

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

1523 

1524 if notify is not None: 

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

1526 if existing_extensions: 

1527 assert existing is not None 

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

1529 "notify", check=True 

1530 ) 

1531 if existing_notify is None: 

1532 change = True 

1533 else: 

1534 if existing_notify.get_config() != notify: 

1535 change = True 

1536 for el in existing_notify: 

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

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

1539 

1540 if change: 

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

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

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

1544 

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

1546 

1547 try: 

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

1549 except PermissionError: 

1550 warnings.warn( 

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

1552 ) 

1553 # fallback by forcing invitation 

1554 bookmark_add_fail = True 

1555 except IqError as e: 

1556 warnings.warn( 

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

1558 ) 

1559 # fallback by forcing invitation 

1560 bookmark_add_fail = True 

1561 else: 

1562 bookmark_add_fail = False 

1563 else: 

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

1565 return 

1566 

1567 if bookmark_add_fail: 

1568 self.session.send_gateway_invite( 

1569 self, 

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

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

1572 "Contact your administrator.", 

1573 ) 

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

1575 "always_invite_when_adding_bookmarks", True 

1576 ): 

1577 self.session.send_gateway_invite( 

1578 self, 

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

1580 ) 

1581 

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

1583 """ 

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

1585 client. 

1586 

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

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

1589 updated on the XMPP side. 

1590 

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

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

1593 room update event. 

1594 

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

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

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

1598 correct. 

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

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

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

1602 """ 

1603 raise NotImplementedError 

1604 

1605 async def on_set_config( 

1606 self, 

1607 name: str | None, 

1608 description: str | None, 

1609 ) -> None: 

1610 """ 

1611 Triggered when the user requests changing the room configuration. 

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

1613 

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

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

1616 

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

1618 be ``None``. 

1619 

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

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

1622 """ 

1623 raise NotImplementedError 

1624 

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

1626 """ 

1627 Triggered when the user requests room destruction. 

1628 

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

1630 """ 

1631 raise NotImplementedError 

1632 

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

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

1635 await self.__fill_participants() 

1636 orm.add(self.stored) 

1637 participants = { 

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

1639 } 

1640 

1641 if len(participants) == 0: 

1642 return () 

1643 

1644 result = [] 

1645 for match in re.finditer( 

1646 "|".join( 

1647 sorted( 

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

1649 key=lambda nick: len(nick), 

1650 reverse=True, 

1651 ) 

1652 ), 

1653 text, 

1654 ): 

1655 span = match.span() 

1656 nick = match.group() 

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

1658 continue 

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

1660 participant = self.participant_from_store( 

1661 stored=participants[nick], 

1662 ) 

1663 if contact := participant.contact: 

1664 result.append( 

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

1666 ) 

1667 return tuple(result) 

1668 

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

1670 """ 

1671 Triggered when the user requests changing the room subject. 

1672 

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

1674 instance. 

1675 

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

1677 """ 

1678 raise NotImplementedError 

1679 

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

1681 """ 

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

1683 

1684 :param thread: Legacy identifier of the thread 

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

1686 """ 

1687 raise NotImplementedError 

1688 

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

1690 """ 

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

1692 a MUC using :xep:`0425`. 

1693 

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

1695 XMPPError with a human-readable message. 

1696 

1697 NB: the legacy module is responsible for calling 

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

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

1700 moderation message from the MUC automatically. 

1701 

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

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

1704 user-moderator. 

1705 """ 

1706 raise NotImplementedError 

1707 

1708 async def on_leave(self) -> None: 

1709 """ 

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

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

1712 

1713 This should be interpreted as definitely leaving the group. 

1714 """ 

1715 raise NotImplementedError 

1716 

1717 @property 

1718 def participants_filled(self) -> bool: 

1719 return self.stored.participants_filled 

1720 

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

1722 """ 

1723 Query the slidge archive for messages sent in this group 

1724 

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

1726 or an XMPP ID. 

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

1728 because of multi-attachment messages. 

1729 """ 

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

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

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

1733 ): 

1734 yield HistoryMessage(stored.stanza) 

1735 

1736 

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

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

1739 sub.attrib["id"] = origin_id 

1740 msg.xml.append(sub) 

1741 

1742 

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

1744 try: 

1745 return int(x) 

1746 except ValueError: 

1747 return None 

1748 

1749 

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

1751 if x is None: 

1752 return False 

1753 else: 

1754 return x == 0 

1755 

1756 

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

1758 if date is None: 

1759 return None 

1760 try: 

1761 return str_to_datetime(date) 

1762 except ValueError: 

1763 return None 

1764 

1765 

1766def bookmarks_form() -> Form: 

1767 form = Form() 

1768 form["type"] = "submit" 

1769 form.add_field( 

1770 "FORM_TYPE", 

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

1772 ftype="hidden", 

1773 ) 

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

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

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

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

1778 return form 

1779 

1780 

1781_BOOKMARKS_OPTIONS = bookmarks_form() 

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

1783 

1784log = logging.getLogger(__name__)