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

796 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-03-13 22:59 +0000

1import json 

2import logging 

3import re 

4import string 

5import uuid 

6import warnings 

7from asyncio import Lock 

8from collections.abc import AsyncIterator, Iterable, Iterator 

9from contextlib import asynccontextmanager 

10from copy import copy 

11from datetime import UTC, datetime, timedelta 

12from typing import ( 

13 TYPE_CHECKING, 

14 Any, 

15 Generic, 

16 Literal, 

17 Union, 

18 overload, 

19) 

20 

21import sqlalchemy as sa 

22from slixmpp import JID, Iq, Message, Presence 

23from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

24from slixmpp.plugins.xep_0004 import Form 

25from slixmpp.plugins.xep_0060.stanza import Item 

26from slixmpp.plugins.xep_0082 import parse as str_to_datetime 

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

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

29from slixmpp.plugins.xep_0492.stanza import WhenLiteral 

30from slixmpp.xmlstream import ET 

31from sqlalchemy.exc import IntegrityError 

32from sqlalchemy.orm import Session as OrmSession 

33 

34from ..contact.contact import LegacyContact 

35from ..contact.roster import ContactIsUser 

36from ..core.mixins.avatar import AvatarMixin 

37from ..core.mixins.disco import ChatterDiscoMixin 

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

39from ..db.models import Participant, Room 

40from ..util.archive_msg import HistoryMessage 

41from ..util.jid_escaping import unescape_node 

42from ..util.types import ( 

43 HoleBound, 

44 LegacyGroupIdType, 

45 LegacyMessageType, 

46 LegacyParticipantType, 

47 LegacyThreadType, 

48 LegacyUserIdType, 

49 Mention, 

50 MucAffiliation, 

51 MucType, 

52) 

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

54from .archive import MessageArchive 

55from .participant import LegacyParticipant, escape_nickname 

56 

57if TYPE_CHECKING: 

58 from ..core.session import BaseSession 

59 from ..db.avatar import CachedAvatar 

60 

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

62 

63SubjectSetterType = Union[str, None, "LegacyContact", "LegacyParticipant"] 

64 

65 

66class LegacyMUC( 

67 Generic[ 

68 LegacyGroupIdType, LegacyMessageType, LegacyParticipantType, LegacyUserIdType 

69 ], 

70 AvatarMixin, 

71 ChatterDiscoMixin, 

72 ReactionRecipientMixin, 

73 ThreadRecipientMixin, 

74 SubclassableOnce, 

75): 

76 """ 

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

78 

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

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

81 """ 

82 

83 max_history_fetch = 100 

84 

85 is_group = True 

86 

87 DISCO_TYPE = "text" 

88 DISCO_CATEGORY = "conference" 

89 

90 STABLE_ARCHIVE = False 

91 """ 

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

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

94 across restarts. 

95 

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

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

98 

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

100 """ 

101 

102 _ALL_INFO_FILLED_ON_STARTUP = False 

103 """ 

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

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

106 """ 

107 

108 HAS_DESCRIPTION = True 

109 """ 

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

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

112 room configuration form. 

113 """ 

114 

115 HAS_SUBJECT = True 

116 """ 

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

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

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

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

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

122 tries to set the room subject. 

123 """ 

124 

125 archive: MessageArchive 

126 session: "BaseSession" 

127 

128 stored: Room 

129 

130 _participant_cls: type[LegacyParticipantType] 

131 

132 def __init__(self, session: "BaseSession", stored: Room) -> None: 

133 self.session = session 

134 self.xmpp = session.xmpp 

135 self.stored = stored 

136 self._set_logger() 

137 super().__init__() 

138 

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

140 

141 if self._ALL_INFO_FILLED_ON_STARTUP: 

142 self.stored.participants_filled = True 

143 

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

145 """ 

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

147 

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

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

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

151 

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

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

154 MUC will be marked as read. 

155 

156 :param horizon_xmpp_id: The latest message 

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

158 """ 

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

160 assert self.stored.id is not None 

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

162 orm, self.stored.id, horizon_xmpp_id 

163 ) 

164 orm.commit() 

165 return ids 

166 

167 def participant_from_store( 

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

169 ) -> LegacyParticipantType: 

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

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

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

173 

174 @property 

175 def jid(self) -> JID: 

176 return self.stored.jid 

177 

178 @jid.setter 

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

180 # FIXME: without this, mypy yields 

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

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

183 raise RuntimeError 

184 

185 @property 

186 def legacy_id(self): 

187 return self.xmpp.LEGACY_ROOM_ID_TYPE(self.stored.legacy_id) 

188 

189 def orm(self, **kwargs) -> OrmSession: 

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

191 

192 @property 

193 def type(self) -> MucType: 

194 return self.stored.muc_type 

195 

196 @type.setter 

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

198 if self.type == type_: 

199 return 

200 self.update_stored_attribute(muc_type=type_) 

201 

202 @property 

203 def n_participants(self): 

204 return self.stored.n_participants 

205 

206 @n_participants.setter 

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

208 if self.stored.n_participants == n_participants: 

209 return 

210 self.update_stored_attribute(n_participants=n_participants) 

211 

212 @property 

213 def user_jid(self) -> JID: 

214 return self.session.user_jid 

215 

216 def _set_logger(self) -> None: 

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

218 

219 def __repr__(self) -> str: 

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

221 

222 @property 

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

224 if self.stored.subject_date is None: 

225 return None 

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

227 

228 @subject_date.setter 

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

230 if self.subject_date == when: 

231 return 

232 self.update_stored_attribute(subject_date=when) 

233 

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

235 part = self.get_system_participant() 

236 part.send_configuration_change(codes) 

237 

238 @property 

239 def user_nick(self) -> str: 

240 return ( 

241 self.stored.user_nick 

242 or self.session.bookmarks.user_nick 

243 or self.user_jid.node 

244 ) 

245 

246 @user_nick.setter 

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

248 if nick == self.user_nick: 

249 return 

250 self.update_stored_attribute(user_nick=nick) 

251 

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

253 stored_set = self.get_user_resources() 

254 if resource in stored_set: 

255 return 

256 stored_set.add(resource) 

257 self.update_stored_attribute( 

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

259 ) 

260 

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

262 stored_str = self.stored.user_resources 

263 if stored_str is None: 

264 return set() 

265 return set(json.loads(stored_str)) 

266 

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

268 stored_set = self.get_user_resources() 

269 if resource not in stored_set: 

270 return 

271 stored_set.remove(resource) 

272 self.update_stored_attribute( 

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

274 ) 

275 

276 @asynccontextmanager 

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

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

279 yield 

280 

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

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

283 

284 async def __fill_participants(self) -> None: 

285 if self._ALL_INFO_FILLED_ON_STARTUP or self.participants_filled: 

286 return 

287 

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

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

290 orm.add(self.stored) 

291 with orm.no_autoflush: 

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

293 if self.participants_filled: 

294 return 

295 parts: list[Participant] = [] 

296 resources = set[str]() 

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

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

299 async for participant in self.fill_participants(): 

300 if participant.stored.resource in resources: 

301 self.log.debug( 

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

303 participant.stored.resource, 

304 ) 

305 continue 

306 parts.append(participant.stored) 

307 resources.add(participant.stored.resource) 

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

309 orm.add(self.stored) 

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

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

312 # and the participant_filled attribute. 

313 with orm.no_autoflush: 

314 orm.refresh(self.stored) 

315 for part in parts: 

316 orm.merge(part) 

317 self.stored.participants_filled = True 

318 orm.commit() 

319 

320 async def get_participants( 

321 self, affiliation: MucAffiliation | None = None 

322 ) -> AsyncIterator[LegacyParticipantType]: 

323 await self.__fill_participants() 

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

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

326 for db_participant in self.stored.participants: 

327 if ( 

328 affiliation is not None 

329 and db_participant.affiliation != affiliation 

330 ): 

331 continue 

332 yield self.participant_from_store(db_participant) 

333 

334 async def __fill_history(self) -> None: 

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

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

337 orm.add(self.stored) 

338 with orm.no_autoflush: 

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

340 if self.stored.history_filled: 

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

342 return 

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

344 try: 

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

346 if before is not None: 

347 before = before._replace( 

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

349 ) 

350 if after is not None: 

351 after = after._replace( 

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

353 ) 

354 await self.backfill(before, after) 

355 except NotImplementedError: 

356 return 

357 except Exception as e: 

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

359 

360 self.stored.history_filled = True 

361 self.commit(merge=True) 

362 

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

364 return self.name 

365 

366 @property 

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

368 return self.stored.name 

369 

370 @name.setter 

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

372 if self.name == n: 

373 return 

374 self.update_stored_attribute(name=n) 

375 self._set_logger() 

376 self.__send_configuration_change((104,)) 

377 

378 @property 

379 def description(self) -> str: 

380 return self.stored.description or "" 

381 

382 @description.setter 

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

384 if self.description == d: 

385 return 

386 self.update_stored_attribute(description=d) 

387 self.__send_configuration_change((104,)) 

388 

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

390 pto = p.get_to() 

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

392 return 

393 

394 pfrom = p.get_from() 

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

396 return 

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

398 if pto.resource != self.user_nick: 

399 self.log.debug( 

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

401 ) 

402 self.remove_user_resource(resource) 

403 else: 

404 self.log.debug( 

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

406 ) 

407 

408 async def update_info(self) -> None: 

409 """ 

410 Fetch information about this group from the legacy network 

411 

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

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

414 of participants etc. 

415 

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

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

418 is no change, you should not call 

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

420 attempt to modify 

421 the :attr:.avatar property. 

422 """ 

423 raise NotImplementedError 

424 

425 async def backfill( 

426 self, 

427 after: HoleBound | None = None, 

428 before: HoleBound | None = None, 

429 ) -> None: 

430 """ 

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

432 

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

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

435 run for a given group. 

436 

437 :param after: Fetch messages after this one. 

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

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

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

441 the user registered. 

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

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

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

445 :param before: Fetch messages before this one. 

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

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

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

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

450 """ 

451 raise NotImplementedError 

452 

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

454 """ 

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

456 

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

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

459 before yielding them. 

460 """ 

461 return 

462 yield 

463 

464 @property 

465 def subject(self) -> str: 

466 return self.stored.subject or "" 

467 

468 @subject.setter 

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

470 if s == self.subject: 

471 return 

472 

473 self.update_stored_attribute(subject=s) 

474 self.__get_subject_setter_participant().set_room_subject( 

475 s, None, self.subject_date, False 

476 ) 

477 

478 @property 

479 def is_anonymous(self) -> bool: 

480 return self.type == MucType.CHANNEL 

481 

482 @property 

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

484 return self.stored.subject_setter 

485 

486 @subject_setter.setter 

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

488 if isinstance(subject_setter, LegacyContact): 

489 subject_setter = subject_setter.name 

490 elif isinstance(subject_setter, LegacyParticipant): 

491 subject_setter = subject_setter.nickname 

492 

493 if subject_setter == self.subject_setter: 

494 return 

495 assert isinstance(subject_setter, str | None) 

496 self.update_stored_attribute(subject_setter=subject_setter) 

497 

498 def __get_subject_setter_participant(self) -> LegacyParticipant: 

499 if self.subject_setter is None: 

500 return self.get_system_participant() 

501 return self._participant_cls( 

502 self, 

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

504 ) 

505 

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

507 features = [ 

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

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

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

511 "urn:xmpp:mam:2", 

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

513 "urn:xmpp:sid:0", 

514 "muc_persistent", 

515 "vcard-temp", 

516 "urn:xmpp:ping", 

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

518 "jabber:iq:register", 

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

520 ] 

521 if self.type == MucType.GROUP: 

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

523 elif self.type == MucType.CHANNEL: 

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

525 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

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

527 return features 

528 

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

530 is_group = self.type == MucType.GROUP 

531 

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

533 

534 form.add_field( 

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

536 ) 

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

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

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

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

541 

542 if self.stored.id is not None and ( 

543 self._ALL_INFO_FILLED_ON_STARTUP or self.stored.participants_filled 

544 ): 

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

546 n = orm.scalar( 

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

548 room_id=self.stored.id 

549 ) 

550 ) 

551 else: 

552 n = self.n_participants 

553 if n is not None: 

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

555 

556 if d := self.description: 

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

558 

559 if s := self.subject: 

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

561 

562 if name := self.name: 

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

564 

565 if self._set_avatar_task is not None: 

566 await self._set_avatar_task 

567 avatar = self.get_avatar() 

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

569 form.add_field( 

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

571 ) 

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

573 

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

575 form.add_field( 

576 "muc#roomconfig_whois", 

577 "list-single", 

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

579 ) 

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

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

582 

583 r = [form] 

584 

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

586 r.append(reaction_form) 

587 

588 return r 

589 

590 def shutdown(self) -> None: 

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

592 for user_full_jid in self.user_full_jids(): 

593 presence = self.xmpp.make_presence( 

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

595 ) 

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

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

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

599 presence.send() 

600 

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

602 for r in self.get_user_resources(): 

603 j = JID(self.user_jid) 

604 j.resource = r 

605 yield j 

606 

607 @property 

608 def user_muc_jid(self) -> JID: 

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

610 return user_muc_jid 

611 

612 async def echo( 

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

614 ) -> str: 

615 msg.set_from(self.user_muc_jid) 

616 if legacy_msg_id: 

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

618 else: 

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

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

621 

622 user_part = await self.get_user_participant() 

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

624 

625 self.archive.add(msg, user_part) 

626 

627 for user_full_jid in self.user_full_jids(): 

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

629 msg = copy(msg) 

630 msg.set_to(user_full_jid) 

631 

632 msg.send() 

633 

634 return msg["stanza_id"]["id"] 

635 

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

637 self.__send_configuration_change((104,)) 

638 self._send_room_presence() 

639 

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

641 if user_full_jid is None: 

642 tos = self.user_full_jids() 

643 else: 

644 tos = [user_full_jid] 

645 for to in tos: 

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

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

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

649 else: 

650 p["vcard_temp_update"]["photo"] = "" 

651 p.send() 

652 

653 @timeit 

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

655 user_full_jid = join_presence.get_from() 

656 requested_nickname = join_presence.get_to().resource 

657 client_resource = user_full_jid.resource 

658 

659 if client_resource in self.get_user_resources(): 

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

661 

662 if not requested_nickname or not client_resource: 

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

664 

665 self.add_user_resource(client_resource) 

666 

667 self.log.debug( 

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

669 client_resource, 

670 self.user_jid, 

671 self.legacy_id, 

672 requested_nickname, 

673 ) 

674 

675 user_nick = self.user_nick 

676 user_participant = None 

677 async for participant in self.get_participants(): 

678 if participant.is_user: 

679 user_participant = participant 

680 continue 

681 participant.send_initial_presence(full_jid=user_full_jid) 

682 

683 if user_participant is None: 

684 user_participant = await self.get_user_participant() 

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

686 orm.add(self.stored) 

687 with orm.no_autoflush: 

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

689 if not user_participant.is_user: 

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

691 user_participant.is_user = True 

692 user_participant.send_initial_presence( 

693 user_full_jid, 

694 presence_id=join_presence["id"], 

695 nick_change=user_nick != requested_nickname, 

696 ) 

697 

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

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

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

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

702 try: 

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

704 except ValueError: 

705 since = None 

706 if seconds is not None: 

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

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

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

710 else: 

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

712 await self.__fill_history() 

713 await self.__old_school_history( 

714 user_full_jid, 

715 maxchars=maxchars, 

716 maxstanzas=maxstanzas, 

717 since=since, 

718 ) 

719 if self.HAS_SUBJECT: 

720 subject = self.subject or "" 

721 else: 

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

723 self.__get_subject_setter_participant().set_room_subject( 

724 subject, 

725 user_full_jid, 

726 self.subject_date, 

727 ) 

728 if t := self._set_avatar_task: 

729 await t 

730 self._send_room_presence(user_full_jid) 

731 

732 async def get_user_participant(self, **kwargs) -> "LegacyParticipantType": 

733 """ 

734 Get the participant representing the gateway user 

735 

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

737 construction (optional) 

738 :return: 

739 """ 

740 p = await self.get_participant(self.user_nick, is_user=True, **kwargs) 

741 self.__store_participant(p) 

742 return p 

743 

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

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

746 return 

747 try: 

748 p.commit(merge=True) 

749 except IntegrityError as e: 

750 if self._ALL_INFO_FILLED_ON_STARTUP: 

751 log.debug("ℂould not store participant: %r", e) 

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

753 self.stored = self.xmpp.store.rooms.get( 

754 orm, self.user_pk, legacy_id=str(self.legacy_id) 

755 ) 

756 p.stored.room = self.stored 

757 orm.add(p.stored) 

758 orm.commit() 

759 else: 

760 log.debug("ℂould not store participant: %r", e) 

761 

762 @overload 

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

764 

765 @overload 

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

767 

768 @overload 

769 async def get_participant( 

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

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

772 

773 @overload 

774 async def get_participant( 

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

776 ) -> "LegacyParticipantType": ... 

777 

778 @overload 

779 async def get_participant( 

780 self, nickname: str, *, occupant_id: str 

781 ) -> "LegacyParticipantType": ... 

782 

783 @overload 

784 async def get_participant( 

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

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

787 

788 @overload 

789 async def get_participant( 

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

791 ) -> "LegacyParticipantType": ... 

792 

793 @overload 

794 async def get_participant( 

795 self, 

796 nickname: str, 

797 *, 

798 create: Literal[True], 

799 is_user: bool, 

800 fill_first: bool, 

801 store: bool, 

802 ) -> "LegacyParticipantType": ... 

803 

804 @overload 

805 async def get_participant( 

806 self, 

807 nickname: str, 

808 *, 

809 create: Literal[False], 

810 is_user: bool, 

811 fill_first: bool, 

812 store: bool, 

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

814 

815 @overload 

816 async def get_participant( 

817 self, 

818 nickname: str, 

819 *, 

820 create: bool, 

821 fill_first: bool, 

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

823 

824 async def get_participant( 

825 self, 

826 nickname: str | None = None, 

827 *, 

828 create: bool = True, 

829 is_user: bool = False, 

830 fill_first: bool = False, 

831 store: bool = True, 

832 occupant_id: str | None = None, 

833 ) -> "LegacyParticipantType | None": 

834 """ 

835 Get a participant by their nickname. 

836 

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

838 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

839 

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

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

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

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

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

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

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

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

848 xep:`0421` 

849 :return: A participant of this room. 

850 """ 

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

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

853 if fill_first: 

854 await self.__fill_participants() 

855 if not self._ALL_INFO_FILLED_ON_STARTUP or self.stored.id is not None: 

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

857 if occupant_id is not None: 

858 stored = ( 

859 orm.query(Participant) 

860 .filter( 

861 Participant.room == self.stored, 

862 Participant.occupant_id == occupant_id, 

863 ) 

864 .one_or_none() 

865 ) 

866 elif nickname is not None: 

867 stored = ( 

868 orm.query(Participant) 

869 .filter( 

870 Participant.room == self.stored, 

871 (Participant.nickname == nickname) 

872 | (Participant.resource == nickname), 

873 ) 

874 .one_or_none() 

875 ) 

876 else: 

877 raise RuntimeError("NEVER") 

878 if stored is not None: 

879 if occupant_id and occupant_id != stored.occupant_id: 

880 warnings.warn( 

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

882 ) 

883 part = self.participant_from_store(stored) 

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

885 stored.nickname = nickname 

886 orm.add(stored) 

887 orm.commit() 

888 return part 

889 

890 if not create: 

891 return None 

892 

893 if occupant_id is None: 

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

895 

896 if nickname is None: 

897 nickname = occupant_id 

898 

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

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

901 if is_user: 

902 self.user_nick = nickname 

903 

904 p = self._participant_cls( 

905 self, 

906 Participant( 

907 room=self.stored, 

908 nickname=nickname or occupant_id, 

909 is_user=is_user, 

910 occupant_id=occupant_id, 

911 ), 

912 ) 

913 if store: 

914 self.__store_participant(p) 

915 if ( 

916 not self.get_lock("fill participants") 

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

918 and self.stored.participants_filled 

919 and not p.is_user 

920 and not p.is_system 

921 ): 

922 p.send_affiliation_change() 

923 return p 

924 

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

926 """ 

927 Get a pseudo-participant, representing the room itself 

928 

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

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

931 service 

932 :return: 

933 """ 

934 return self._participant_cls( 

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

936 ) 

937 

938 @overload 

939 async def get_participant_by_contact( 

940 self, c: "LegacyContact[Any]" 

941 ) -> "LegacyParticipantType": ... 

942 

943 @overload 

944 async def get_participant_by_contact( 

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

946 ) -> "LegacyParticipantType": ... 

947 

948 @overload 

949 async def get_participant_by_contact( 

950 self, 

951 c: "LegacyContact[Any]", 

952 *, 

953 create: Literal[False], 

954 occupant_id: str | None, 

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

956 

957 @overload 

958 async def get_participant_by_contact( 

959 self, 

960 c: "LegacyContact[Any]", 

961 *, 

962 create: Literal[True], 

963 occupant_id: str | None, 

964 ) -> "LegacyParticipantType": ... 

965 

966 async def get_participant_by_contact( 

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

968 ) -> "LegacyParticipantType | None": 

969 """ 

970 Get a non-anonymous participant. 

971 

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

973 that the Contact jid is associated to this participant 

974 

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

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

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

978 this participant. 

979 :return: 

980 """ 

981 await self.session.contacts.ready 

982 

983 if not self._ALL_INFO_FILLED_ON_STARTUP or self.stored.id is not None: 

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

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

986 stored = ( 

987 orm.query(Participant) 

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

989 .one_or_none() 

990 ) 

991 if stored is None: 

992 if occupant_id is not None: 

993 stored = ( 

994 orm.query(Participant) 

995 .filter_by( 

996 occupant_id=occupant_id, 

997 room=self.stored, 

998 contact_id=None, 

999 ) 

1000 .one_or_none() 

1001 ) 

1002 if stored is not None: 

1003 self.log.debug( 

1004 "Updating the contact of a previously anonymous participant" 

1005 ) 

1006 stored.contact_id = c.stored.id 

1007 orm.add(stored) 

1008 orm.commit() 

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

1010 if not create: 

1011 return None 

1012 else: 

1013 if occupant_id and stored.occupant_id != occupant_id: 

1014 warnings.warn( 

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

1016 ) 

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

1018 

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

1020 

1021 if self.stored.id is None: 

1022 nick_available = True 

1023 else: 

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

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

1026 orm, self.stored.id, nickname 

1027 ) 

1028 

1029 if not nick_available: 

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

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

1032 p = self._participant_cls( 

1033 self, 

1034 Participant( 

1035 nickname=nickname, 

1036 room=self.stored, 

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

1038 ), 

1039 contact=c, 

1040 ) 

1041 

1042 self.__store_participant(p) 

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

1044 # during participants fill and history backfill we do not 

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

1046 # and role afterwards. 

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

1048 if ( 

1049 self.stored.participants_filled 

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

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

1052 ): 

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

1054 return p 

1055 

1056 @overload 

1057 async def get_participant_by_legacy_id( 

1058 self, legacy_id: LegacyUserIdType 

1059 ) -> "LegacyParticipantType": ... 

1060 

1061 @overload 

1062 async def get_participant_by_legacy_id( 

1063 self, 

1064 legacy_id: LegacyUserIdType, 

1065 *, 

1066 occupant_id: str | None, 

1067 create: Literal[True], 

1068 ) -> "LegacyParticipantType": ... 

1069 

1070 @overload 

1071 async def get_participant_by_legacy_id( 

1072 self, 

1073 legacy_id: LegacyUserIdType, 

1074 *, 

1075 occupant_id: str | None, 

1076 ) -> "LegacyParticipantType": ... 

1077 

1078 @overload 

1079 async def get_participant_by_legacy_id( 

1080 self, 

1081 legacy_id: LegacyUserIdType, 

1082 *, 

1083 occupant_id: str | None, 

1084 create: Literal[False], 

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

1086 

1087 async def get_participant_by_legacy_id( 

1088 self, 

1089 legacy_id: LegacyUserIdType, 

1090 *, 

1091 occupant_id: str | None = None, 

1092 create: bool = True, 

1093 ) -> "LegacyParticipantType": 

1094 try: 

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

1096 except ContactIsUser: 

1097 return await self.get_user_participant(occupant_id=occupant_id) 

1098 return await self.get_participant_by_contact( # type:ignore[call-overload] 

1099 c, create=create, occupant_id=occupant_id 

1100 ) 

1101 

1102 def remove_participant( 

1103 self, 

1104 p: "LegacyParticipantType", 

1105 kick: bool = False, 

1106 ban: bool = False, 

1107 reason: str | None = None, 

1108 ) -> None: 

1109 """ 

1110 Call this when a participant leaves the room 

1111 

1112 :param p: The participant 

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

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

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

1116 """ 

1117 if kick and ban: 

1118 raise TypeError("Either kick or ban") 

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

1120 orm.delete(p.stored) 

1121 orm.commit() 

1122 if kick: 

1123 codes = {307} 

1124 elif ban: 

1125 codes = {301} 

1126 else: 

1127 codes = None 

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

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

1130 p.stored.role = "none" 

1131 if reason: 

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

1133 p._send(presence) 

1134 

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

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

1137 stored = ( 

1138 orm.query(Participant) 

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

1140 .one_or_none() 

1141 ) 

1142 if stored is None: 

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

1144 return 

1145 p = self.participant_from_store(stored) 

1146 if p.nickname == old_nickname: 

1147 p.nickname = new_nickname 

1148 

1149 async def __old_school_history( 

1150 self, 

1151 full_jid: JID, 

1152 maxchars: int | None = None, 

1153 maxstanzas: int | None = None, 

1154 seconds: int | None = None, 

1155 since: datetime | None = None, 

1156 ) -> None: 

1157 """ 

1158 Old-style history join (internal slidge use) 

1159 

1160 :param full_jid: 

1161 :param maxchars: 

1162 :param maxstanzas: 

1163 :param seconds: 

1164 :param since: 

1165 :return: 

1166 """ 

1167 if since is None: 

1168 if seconds is None: 

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

1170 else: 

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

1172 else: 

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

1174 

1175 for h_msg in self.archive.get_all( 

1176 start_date=start_date, end_date=None, last_page_n=maxstanzas 

1177 ): 

1178 msg = h_msg.stanza_component_ns 

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

1180 msg.set_to(full_jid) 

1181 self.xmpp.send(msg, False) 

1182 

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

1184 await self.__fill_history() 

1185 

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

1187 

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

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

1190 

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

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

1193 

1194 sender = form_values.get("with") 

1195 

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

1197 

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

1199 try: 

1200 max_results = int(max_str) 

1201 except ValueError: 

1202 max_results = None 

1203 else: 

1204 max_results = None 

1205 

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

1207 after_id = after_id_rsm or after_id 

1208 

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

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

1211 last_page_n = max_results 

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

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

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

1215 if before_rsm is not True: 

1216 before_id = before_rsm 

1217 else: 

1218 last_page_n = None 

1219 

1220 first = None 

1221 last = None 

1222 count = 0 

1223 

1224 it = self.archive.get_all( 

1225 start_date, 

1226 end_date, 

1227 before_id, 

1228 after_id, 

1229 ids, 

1230 last_page_n, 

1231 sender, 

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

1233 ) 

1234 

1235 for history_msg in it: 

1236 last = xmpp_id = history_msg.id 

1237 if first is None: 

1238 first = xmpp_id 

1239 

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

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

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

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

1244 

1245 wrapper_msg.send() 

1246 count += 1 

1247 

1248 if max_results and count == max_results: 

1249 break 

1250 

1251 if max_results: 

1252 try: 

1253 next(it) 

1254 except StopIteration: 

1255 complete = True 

1256 else: 

1257 complete = False 

1258 else: 

1259 complete = True 

1260 

1261 reply = iq.reply() 

1262 if not self.STABLE_ARCHIVE: 

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

1264 if complete: 

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

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

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

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

1269 reply.send() 

1270 

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

1272 await self.__fill_history() 

1273 await self.archive.send_metadata(iq) 

1274 

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

1276 """ 

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

1278 

1279 :param r: The resource to kick 

1280 """ 

1281 pto = JID(self.user_jid) 

1282 pto.resource = r 

1283 p = self.xmpp.make_presence( 

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

1285 ) 

1286 p["type"] = "unavailable" 

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

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

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

1290 p.send() 

1291 

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

1293 item = Item() 

1294 item["id"] = self.jid 

1295 

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

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

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

1299 

1300 try: 

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

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

1303 return None 

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

1305 # (slixmpp annoying magic) 

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

1307 item["id"] = self.jid 

1308 return item 

1309 except IqTimeout: 

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

1311 return None 

1312 except IqError as exc: 

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

1314 return None 

1315 except PermissionError: 

1316 warnings.warn( 

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

1318 ) 

1319 return None 

1320 

1321 async def add_to_bookmarks( 

1322 self, 

1323 auto_join: bool = True, 

1324 preserve: bool = True, 

1325 pin: bool | None = None, 

1326 notify: WhenLiteral | None = None, 

1327 ) -> None: 

1328 """ 

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

1330 

1331 This requires that slidge has the IQ privileged set correctly 

1332 on the XMPP server 

1333 

1334 :param auto_join: whether XMPP clients should automatically join 

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

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

1337 join if they are online. 

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

1339 set by the user outside slidge 

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

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

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

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

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

1345 """ 

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

1347 

1348 new = Item() 

1349 new["id"] = self.jid 

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

1351 

1352 if existing is None: 

1353 change = True 

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

1355 else: 

1356 change = False 

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

1358 

1359 existing_extensions = existing is not None and existing[ 

1360 "conference" 

1361 ].get_plugin("extensions", check=True) 

1362 

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

1364 if existing_extensions: 

1365 assert existing is not None 

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

1367 if el.tag.startswith(f"{{{NOTIFY_NS}}}"): 

1368 if notify is not None: 

1369 continue 

1370 if el.tag.startswith(f"{{{PINNING_NS}}}"): 

1371 if pin is not None: 

1372 continue 

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

1374 

1375 if pin is not None: 

1376 if existing_extensions: 

1377 assert existing is not None 

1378 existing_pin = ( 

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

1380 "pinned", check=True 

1381 ) 

1382 is not None 

1383 ) 

1384 if existing_pin != pin: 

1385 change = True 

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

1387 

1388 if notify is not None: 

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

1390 if existing_extensions: 

1391 assert existing is not None 

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

1393 "notify", check=True 

1394 ) 

1395 if existing_notify is None: 

1396 change = True 

1397 else: 

1398 if existing_notify.get_config() != notify: 

1399 change = True 

1400 for el in existing_notify: 

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

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

1403 

1404 if change: 

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

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

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

1408 

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

1410 

1411 try: 

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

1413 except PermissionError: 

1414 warnings.warn( 

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

1416 ) 

1417 # fallback by forcing invitation 

1418 bookmark_add_fail = True 

1419 except IqError as e: 

1420 warnings.warn( 

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

1422 ) 

1423 # fallback by forcing invitation 

1424 bookmark_add_fail = True 

1425 else: 

1426 bookmark_add_fail = False 

1427 else: 

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

1429 return 

1430 

1431 if bookmark_add_fail: 

1432 self.session.send_gateway_invite( 

1433 self, 

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

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

1436 "Contact your administrator.", 

1437 ) 

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

1439 "always_invite_when_adding_bookmarks", True 

1440 ): 

1441 self.session.send_gateway_invite( 

1442 self, 

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

1444 ) 

1445 

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

1447 """ 

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

1449 client. 

1450 

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

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

1453 updated on the XMPP side. 

1454 

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

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

1457 room update event. 

1458 

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

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

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

1462 correct. 

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

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

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

1466 """ 

1467 raise NotImplementedError 

1468 

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

1470 

1471 async def on_set_affiliation( 

1472 self, 

1473 contact: "LegacyContact", 

1474 affiliation: MucAffiliation, 

1475 reason: str | None, 

1476 nickname: str | None, 

1477 ) -> None: 

1478 """ 

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

1480 for this group. 

1481 

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

1483 

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

1485 :param affiliation: The new affiliation 

1486 :param reason: A reason for this affiliation change 

1487 :param nickname: 

1488 """ 

1489 raise NotImplementedError 

1490 

1491 async def on_kick(self, contact: "LegacyContact", reason: str | None) -> None: 

1492 """ 

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

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

1495 

1496 :param contact: Contact to be kicked 

1497 :param reason: A reason for this kick 

1498 """ 

1499 raise NotImplementedError 

1500 

1501 async def on_set_config( 

1502 self, 

1503 name: str | None, 

1504 description: str | None, 

1505 ) -> None: 

1506 """ 

1507 Triggered when the user requests changing the room configuration. 

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

1509 

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

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

1512 

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

1514 be ``None``. 

1515 

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

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

1518 """ 

1519 raise NotImplementedError 

1520 

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

1522 """ 

1523 Triggered when the user requests room destruction. 

1524 

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

1526 """ 

1527 raise NotImplementedError 

1528 

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

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

1531 await self.__fill_participants() 

1532 orm.add(self.stored) 

1533 participants = {p.nickname: p for p in self.stored.participants} 

1534 

1535 if len(participants) == 0: 

1536 return [] 

1537 

1538 result = [] 

1539 for match in re.finditer( 

1540 "|".join( 

1541 sorted( 

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

1543 key=lambda nick: len(nick), 

1544 reverse=True, 

1545 ) 

1546 ), 

1547 text, 

1548 ): 

1549 span = match.span() 

1550 nick = match.group() 

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

1552 continue 

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

1554 participant = self.participant_from_store( 

1555 stored=participants[nick], 

1556 ) 

1557 if contact := participant.contact: 

1558 result.append( 

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

1560 ) 

1561 return result 

1562 

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

1564 """ 

1565 Triggered when the user requests changing the room subject. 

1566 

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

1568 instance. 

1569 

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

1571 """ 

1572 raise NotImplementedError 

1573 

1574 async def on_set_thread_subject( 

1575 self, thread: LegacyThreadType, subject: str 

1576 ) -> None: 

1577 """ 

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

1579 

1580 :param thread: Legacy identifier of the thread 

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

1582 """ 

1583 raise NotImplementedError 

1584 

1585 @property 

1586 def participants_filled(self) -> bool: 

1587 return self.stored.participants_filled 

1588 

1589 def get_archived_messages( 

1590 self, msg_id: LegacyMessageType | str 

1591 ) -> Iterator[HistoryMessage]: 

1592 """ 

1593 Query the slidge archive for messages sent in this group 

1594 

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

1596 or an XMPP ID. 

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

1598 because of multi-attachment messages. 

1599 """ 

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

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

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

1603 ): 

1604 yield HistoryMessage(stored.stanza) 

1605 

1606 

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

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

1609 sub.attrib["id"] = origin_id 

1610 msg.xml.append(sub) 

1611 

1612 

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

1614 try: 

1615 return int(x) 

1616 except ValueError: 

1617 return None 

1618 

1619 

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

1621 if x is None: 

1622 return False 

1623 else: 

1624 return x == 0 

1625 

1626 

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

1628 if date is None: 

1629 return None 

1630 try: 

1631 return str_to_datetime(date) 

1632 except ValueError: 

1633 return None 

1634 

1635 

1636def bookmarks_form() -> Form: 

1637 form = Form() 

1638 form["type"] = "submit" 

1639 form.add_field( 

1640 "FORM_TYPE", 

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

1642 ftype="hidden", 

1643 ) 

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

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

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

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

1648 return form 

1649 

1650 

1651_BOOKMARKS_OPTIONS = bookmarks_form() 

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

1653 

1654log = logging.getLogger(__name__)