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

803 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 05:07 +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 ClassVar, 

16 Generic, 

17 Literal, 

18 Union, 

19 overload, 

20) 

21 

22import sqlalchemy as sa 

23from slixmpp import JID, Iq, Message, Presence 

24from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

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

26from slixmpp.plugins.xep_0060.stanza import Item 

27from slixmpp.plugins.xep_0082 import parse as str_to_datetime 

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

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

30from slixmpp.plugins.xep_0492.stanza import WhenLiteral 

31from slixmpp.xmlstream import ET 

32from sqlalchemy.exc import IntegrityError 

33from sqlalchemy.orm import Session as OrmSession 

34 

35from ..contact.contact import LegacyContact 

36from ..contact.roster import ContactIsUser 

37from ..core.mixins.avatar import AvatarMixin 

38from ..core.mixins.disco import ChatterDiscoMixin 

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

40from ..db.models import Participant, Room 

41from ..util.archive_msg import HistoryMessage 

42from ..util.jid_escaping import unescape_node 

43from ..util.types import ( 

44 AnyContact, 

45 AnySession, 

46 HoleBound, 

47 LegacyGroupIdType, 

48 LegacyMessageType, 

49 LegacyParticipantType, 

50 LegacyThreadType, 

51 LegacyUserIdType, 

52 Mention, 

53 MucAffiliation, 

54 MucType, 

55) 

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

57from .archive import MessageArchive 

58from .participant import LegacyParticipant, escape_nickname 

59 

60if TYPE_CHECKING: 

61 from ..command.base import MUCCommand 

62 from ..db.avatar import CachedAvatar 

63 

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

65 

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

67 

68 

69class LegacyMUC( 

70 Generic[ 

71 LegacyGroupIdType, LegacyMessageType, LegacyParticipantType, LegacyUserIdType 

72 ], 

73 AvatarMixin, 

74 ChatterDiscoMixin, 

75 ReactionRecipientMixin, 

76 ThreadRecipientMixin, 

77 SubclassableOnce, 

78): 

79 """ 

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

81 

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

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

84 """ 

85 

86 max_history_fetch = 100 

87 

88 is_group = True 

89 

90 DISCO_TYPE = "text" 

91 DISCO_CATEGORY = "conference" 

92 

93 STABLE_ARCHIVE = False 

94 """ 

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

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

97 across restarts. 

98 

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

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

101 

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

103 """ 

104 

105 _ALL_INFO_FILLED_ON_STARTUP = False 

106 """ 

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

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

109 """ 

110 

111 HAS_DESCRIPTION = True 

112 """ 

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

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

115 room configuration form. 

116 """ 

117 

118 HAS_SUBJECT = True 

119 """ 

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

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

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

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

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

125 tries to set the room subject. 

126 """ 

127 

128 archive: MessageArchive 

129 session: AnySession 

130 

131 stored: Room 

132 

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

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

135 

136 _participant_cls: type[LegacyParticipantType] 

137 

138 is_participant: Literal[False] = False 

139 

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

141 self.session = session 

142 self.xmpp = session.xmpp 

143 self.stored = stored 

144 self._set_logger() 

145 super().__init__() 

146 

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

148 

149 if self._ALL_INFO_FILLED_ON_STARTUP: 

150 self.stored.participants_filled = True 

151 

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

153 """ 

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

155 

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

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

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

159 

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

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

162 MUC will be marked as read. 

163 

164 :param horizon_xmpp_id: The latest message 

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

166 """ 

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

168 assert self.stored.id is not None 

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

170 orm, self.stored.id, horizon_xmpp_id 

171 ) 

172 orm.commit() 

173 return ids 

174 

175 def participant_from_store( 

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

177 ) -> LegacyParticipantType: 

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

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

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

181 

182 @property 

183 def jid(self) -> JID: 

184 return self.stored.jid 

185 

186 @jid.setter 

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

188 # FIXME: without this, mypy yields 

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

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

191 raise RuntimeError 

192 

193 @property 

194 def legacy_id(self) -> LegacyGroupIdType: 

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

196 

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

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

199 

200 @property 

201 def type(self) -> MucType: 

202 return self.stored.muc_type 

203 

204 @type.setter 

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

206 if self.type == type_: 

207 return 

208 self.update_stored_attribute(muc_type=type_) 

209 

210 @property 

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

212 return self.stored.n_participants 

213 

214 @n_participants.setter 

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

216 if self.stored.n_participants == n_participants: 

217 return 

218 self.update_stored_attribute(n_participants=n_participants) 

219 

220 @property 

221 def user_jid(self) -> JID: 

222 return self.session.user_jid 

223 

224 def _set_logger(self) -> None: 

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

226 

227 def __repr__(self) -> str: 

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

229 

230 @property 

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

232 if self.stored.subject_date is None: 

233 return None 

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

235 

236 @subject_date.setter 

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

238 if self.subject_date == when: 

239 return 

240 self.update_stored_attribute(subject_date=when) 

241 

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

243 part = self.get_system_participant() 

244 part.send_configuration_change(codes) 

245 

246 @property 

247 def user_nick(self) -> str: 

248 return ( 

249 self.stored.user_nick 

250 or self.session.bookmarks.user_nick 

251 or self.user_jid.node 

252 ) 

253 

254 @user_nick.setter 

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

256 if nick == self.user_nick: 

257 return 

258 self.update_stored_attribute(user_nick=nick) 

259 

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

261 stored_set = self.get_user_resources() 

262 if resource in stored_set: 

263 return 

264 stored_set.add(resource) 

265 self.update_stored_attribute( 

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

267 ) 

268 

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

270 stored_str = self.stored.user_resources 

271 if stored_str is None: 

272 return set() 

273 return set(json.loads(stored_str)) 

274 

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

276 stored_set = self.get_user_resources() 

277 if resource not in stored_set: 

278 return 

279 stored_set.remove(resource) 

280 self.update_stored_attribute( 

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

282 ) 

283 

284 @asynccontextmanager 

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

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

287 yield 

288 

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

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

291 

292 async def __fill_participants(self) -> None: 

293 if self._ALL_INFO_FILLED_ON_STARTUP or self.participants_filled: 

294 return 

295 

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

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

298 orm.add(self.stored) 

299 with orm.no_autoflush: 

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

301 if self.participants_filled: 

302 return 

303 parts: list[Participant] = [] 

304 resources = set[str]() 

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

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

307 async for participant in self.fill_participants(): 

308 if participant.stored.resource in resources: 

309 self.log.debug( 

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

311 participant.stored.resource, 

312 ) 

313 continue 

314 parts.append(participant.stored) 

315 resources.add(participant.stored.resource) 

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

317 orm.add(self.stored) 

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

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

320 # and the participant_filled attribute. 

321 with orm.no_autoflush: 

322 orm.refresh(self.stored) 

323 for part in parts: 

324 orm.merge(part) 

325 self.stored.participants_filled = True 

326 orm.commit() 

327 

328 async def get_participants( 

329 self, affiliation: MucAffiliation | None = None 

330 ) -> AsyncIterator[LegacyParticipantType]: 

331 await self.__fill_participants() 

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

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

334 for db_participant in self.stored.participants: 

335 if ( 

336 affiliation is not None 

337 and db_participant.affiliation != affiliation 

338 ): 

339 continue 

340 yield self.participant_from_store(db_participant) 

341 

342 async def __fill_history(self) -> None: 

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

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

345 orm.add(self.stored) 

346 with orm.no_autoflush: 

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

348 if self.stored.history_filled: 

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

350 return 

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

352 try: 

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

354 if before is not None: 

355 before = before._replace( 

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

357 ) 

358 if after is not None: 

359 after = after._replace( 

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

361 ) 

362 await self.backfill(before, after) 

363 except NotImplementedError: 

364 return 

365 except Exception as e: 

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

367 

368 self.stored.history_filled = True 

369 self.commit(merge=True) 

370 

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

372 return self.name 

373 

374 @property 

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

376 return self.stored.name 

377 

378 @name.setter 

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

380 if self.name == n: 

381 return 

382 self.update_stored_attribute(name=n) 

383 self._set_logger() 

384 self.__send_configuration_change((104,)) 

385 

386 @property 

387 def description(self) -> str: 

388 return self.stored.description or "" 

389 

390 @description.setter 

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

392 if self.description == d: 

393 return 

394 self.update_stored_attribute(description=d) 

395 self.__send_configuration_change((104,)) 

396 

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

398 pto = p.get_to() 

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

400 return 

401 

402 pfrom = p.get_from() 

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

404 return 

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

406 if pto.resource != self.user_nick: 

407 self.log.debug( 

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

409 ) 

410 self.remove_user_resource(resource) 

411 else: 

412 self.log.debug( 

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

414 ) 

415 

416 async def update_info(self) -> None: 

417 """ 

418 Fetch information about this group from the legacy network 

419 

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

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

422 of participants etc. 

423 

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

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

426 is no change, you should not call 

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

428 attempt to modify 

429 the :attr:.avatar property. 

430 """ 

431 raise NotImplementedError 

432 

433 async def backfill( 

434 self, 

435 after: HoleBound | None = None, 

436 before: HoleBound | None = None, 

437 ) -> None: 

438 """ 

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

440 

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

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

443 run for a given group. 

444 

445 :param after: Fetch messages after this one. 

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

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

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

449 the user registered. 

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

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

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

453 :param before: Fetch messages before this one. 

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

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

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

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

458 """ 

459 raise NotImplementedError 

460 

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

462 """ 

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

464 

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

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

467 before yielding them. 

468 """ 

469 return 

470 yield 

471 

472 @property 

473 def subject(self) -> str: 

474 return self.stored.subject or "" 

475 

476 @subject.setter 

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

478 if s == self.subject: 

479 return 

480 

481 self.update_stored_attribute(subject=s) 

482 self.__get_subject_setter_participant().set_room_subject( 

483 s, None, self.subject_date, False 

484 ) 

485 

486 @property 

487 def is_anonymous(self) -> bool: 

488 return self.type == MucType.CHANNEL 

489 

490 @property 

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

492 return self.stored.subject_setter 

493 

494 @subject_setter.setter 

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

496 if isinstance(subject_setter, LegacyContact): 

497 subject_setter = subject_setter.name 

498 elif isinstance(subject_setter, LegacyParticipant): 

499 subject_setter = subject_setter.nickname 

500 

501 if subject_setter == self.subject_setter: 

502 return 

503 assert isinstance(subject_setter, str | None) 

504 self.update_stored_attribute(subject_setter=subject_setter) 

505 

506 def __get_subject_setter_participant(self) -> LegacyParticipant: 

507 if self.subject_setter is None: 

508 return self.get_system_participant() 

509 return self._participant_cls( 

510 self, 

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

512 ) 

513 

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

515 features = [ 

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

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

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

519 "urn:xmpp:mam:2", 

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

521 "urn:xmpp:sid:0", 

522 "muc_persistent", 

523 "vcard-temp", 

524 "urn:xmpp:ping", 

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

526 "jabber:iq:register", 

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

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

529 ] 

530 if self.type == MucType.GROUP: 

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

532 elif self.type == MucType.CHANNEL: 

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

534 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

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

536 return features 

537 

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

539 is_group = self.type == MucType.GROUP 

540 

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

542 

543 form.add_field( 

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

545 ) 

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

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

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

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

550 

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

552 self._ALL_INFO_FILLED_ON_STARTUP or self.stored.participants_filled 

553 ): 

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

555 n = orm.scalar( 

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

557 room_id=self.stored.id 

558 ) 

559 ) 

560 else: 

561 n = self.n_participants 

562 if n is not None: 

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

564 

565 if d := self.description: 

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

567 

568 if s := self.subject: 

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

570 

571 if name := self.name: 

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

573 

574 if self._set_avatar_task is not None: 

575 await self._set_avatar_task 

576 avatar = self.get_avatar() 

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

578 form.add_field( 

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

580 ) 

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

582 

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

584 form.add_field( 

585 "muc#roomconfig_whois", 

586 "list-single", 

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

588 ) 

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

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

591 

592 r = [form] 

593 

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

595 r.append(reaction_form) 

596 

597 return r 

598 

599 def shutdown(self) -> None: 

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

601 for user_full_jid in self.user_full_jids(): 

602 presence = self.xmpp.make_presence( 

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

604 ) 

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

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

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

608 presence.send() 

609 

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

611 for r in self.get_user_resources(): 

612 j = JID(self.user_jid) 

613 j.resource = r 

614 yield j 

615 

616 @property 

617 def user_muc_jid(self) -> JID: 

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

619 return user_muc_jid 

620 

621 async def echo( 

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

623 ) -> str: 

624 msg.set_from(self.user_muc_jid) 

625 if legacy_msg_id: 

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

627 else: 

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

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

630 

631 user_part = await self.get_user_participant() 

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

633 

634 self.archive.add(msg, user_part) 

635 

636 for user_full_jid in self.user_full_jids(): 

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

638 msg = copy(msg) 

639 msg.set_to(user_full_jid) 

640 

641 msg.send() 

642 

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

644 

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

646 self.__send_configuration_change((104,)) 

647 self._send_room_presence() 

648 

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

650 if user_full_jid is None: 

651 tos = self.user_full_jids() 

652 else: 

653 tos = [user_full_jid] 

654 for to in tos: 

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

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

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

658 else: 

659 p["vcard_temp_update"]["photo"] = "" 

660 p.send() 

661 

662 @timeit 

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

664 user_full_jid = join_presence.get_from() 

665 requested_nickname = join_presence.get_to().resource 

666 client_resource = user_full_jid.resource 

667 

668 if client_resource in self.get_user_resources(): 

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

670 

671 if not requested_nickname or not client_resource: 

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

673 

674 self.add_user_resource(client_resource) 

675 

676 self.log.debug( 

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

678 client_resource, 

679 self.user_jid, 

680 self.legacy_id, 

681 requested_nickname, 

682 ) 

683 

684 user_nick = self.user_nick 

685 user_participant = None 

686 async for participant in self.get_participants(): 

687 if participant.is_user: 

688 user_participant = participant 

689 continue 

690 participant.send_initial_presence(full_jid=user_full_jid) 

691 

692 if user_participant is None: 

693 user_participant = await self.get_user_participant() 

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

695 orm.add(self.stored) 

696 with orm.no_autoflush: 

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

698 if not user_participant.is_user: 

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

700 user_participant.is_user = True 

701 user_participant.send_initial_presence( 

702 user_full_jid, 

703 presence_id=join_presence["id"], 

704 nick_change=user_nick != requested_nickname, 

705 ) 

706 

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

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

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

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

711 try: 

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

713 except ValueError: 

714 since = None 

715 if seconds is not None: 

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

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

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

719 else: 

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

721 await self.__fill_history() 

722 await self.__old_school_history( 

723 user_full_jid, 

724 maxchars=maxchars, 

725 maxstanzas=maxstanzas, 

726 since=since, 

727 ) 

728 if self.HAS_SUBJECT: 

729 subject = self.subject or "" 

730 else: 

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

732 self.__get_subject_setter_participant().set_room_subject( 

733 subject, 

734 user_full_jid, 

735 self.subject_date, 

736 ) 

737 if t := self._set_avatar_task: 

738 await t 

739 self._send_room_presence(user_full_jid) 

740 

741 async def get_user_participant( 

742 self, 

743 *, 

744 fill_first: bool = False, 

745 store: bool = True, 

746 occupant_id: str | None = None, 

747 ) -> "LegacyParticipantType": 

748 """ 

749 Get the participant representing the gateway user 

750 

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

752 construction (optional) 

753 :return: 

754 """ 

755 p = await self.get_participant( 

756 self.user_nick, 

757 is_user=True, 

758 fill_first=fill_first, 

759 store=store, 

760 occupant_id=occupant_id, 

761 ) 

762 self.__store_participant(p) 

763 return p 

764 

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

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

767 return 

768 try: 

769 p.commit(merge=True) 

770 except IntegrityError as e: 

771 if self._ALL_INFO_FILLED_ON_STARTUP: 

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

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

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

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

776 ) 

777 p.stored.room = self.stored 

778 orm.add(p.stored) 

779 orm.commit() 

780 else: 

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

782 

783 @overload 

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

785 

786 @overload 

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

788 

789 @overload 

790 async def get_participant( 

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

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

793 

794 @overload 

795 async def get_participant( 

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

797 ) -> "LegacyParticipantType": ... 

798 

799 @overload 

800 async def get_participant( 

801 self, nickname: str, *, occupant_id: str 

802 ) -> "LegacyParticipantType": ... 

803 

804 @overload 

805 async def get_participant( 

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

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

808 

809 @overload 

810 async def get_participant( 

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

812 ) -> "LegacyParticipantType": ... 

813 

814 @overload 

815 async def get_participant( 

816 self, 

817 nickname: str, 

818 *, 

819 create: Literal[True], 

820 is_user: bool, 

821 fill_first: bool, 

822 store: bool, 

823 ) -> "LegacyParticipantType": ... 

824 

825 @overload 

826 async def get_participant( 

827 self, 

828 nickname: str, 

829 *, 

830 create: Literal[False], 

831 is_user: bool, 

832 fill_first: bool, 

833 store: bool, 

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

835 

836 @overload 

837 async def get_participant( 

838 self, 

839 nickname: str, 

840 *, 

841 create: bool, 

842 fill_first: bool, 

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

844 

845 @overload 

846 async def get_participant( 

847 self, 

848 nickname: str, 

849 *, 

850 is_user: Literal[True], 

851 fill_first: bool, 

852 store: bool, 

853 occupant_id: str | None = None, 

854 ) -> "LegacyParticipantType": ... 

855 

856 async def get_participant( 

857 self, 

858 nickname: str | None = None, 

859 *, 

860 create: bool = True, 

861 is_user: bool = False, 

862 fill_first: bool = False, 

863 store: bool = True, 

864 occupant_id: str | None = None, 

865 ) -> "LegacyParticipantType | None": 

866 """ 

867 Get a participant by their nickname. 

868 

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

870 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

871 

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

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

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

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

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

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

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

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

880 xep:`0421` 

881 :return: A participant of this room. 

882 """ 

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

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

885 if fill_first: 

886 await self.__fill_participants() 

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

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

889 if occupant_id is not None: 

890 stored = ( 

891 orm.query(Participant) 

892 .filter( 

893 Participant.room == self.stored, 

894 Participant.occupant_id == occupant_id, 

895 ) 

896 .one_or_none() 

897 ) 

898 elif nickname is not None: 

899 stored = ( 

900 orm.query(Participant) 

901 .filter( 

902 Participant.room == self.stored, 

903 (Participant.nickname == nickname) 

904 | (Participant.resource == nickname), 

905 ) 

906 .one_or_none() 

907 ) 

908 else: 

909 raise RuntimeError("NEVER") 

910 if stored is not None: 

911 if occupant_id and occupant_id != stored.occupant_id: 

912 warnings.warn( 

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

914 ) 

915 part = self.participant_from_store(stored) 

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

917 stored.nickname = nickname 

918 orm.add(stored) 

919 orm.commit() 

920 return part 

921 

922 if not create: 

923 return None 

924 

925 if occupant_id is None: 

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

927 

928 if nickname is None: 

929 nickname = occupant_id 

930 

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

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

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

934 if is_user: 

935 self.user_nick = nickname 

936 

937 p = self._participant_cls( 

938 self, 

939 Participant( 

940 room=self.stored, 

941 nickname=nickname or occupant_id, 

942 is_user=is_user, 

943 occupant_id=occupant_id, 

944 ), 

945 ) 

946 if store: 

947 self.__store_participant(p) 

948 if ( 

949 not self.get_lock("fill participants") 

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

951 and self.stored.participants_filled 

952 and not p.is_user 

953 and not p.is_system 

954 ): 

955 p.send_affiliation_change() 

956 return p 

957 

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

959 """ 

960 Get a pseudo-participant, representing the room itself 

961 

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

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

964 service 

965 :return: 

966 """ 

967 return self._participant_cls( 

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

969 ) 

970 

971 @overload 

972 async def get_participant_by_contact( 

973 self, c: "LegacyContact[Any]" 

974 ) -> "LegacyParticipantType": ... 

975 

976 @overload 

977 async def get_participant_by_contact( 

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

979 ) -> "LegacyParticipantType": ... 

980 

981 @overload 

982 async def get_participant_by_contact( 

983 self, 

984 c: "LegacyContact[Any]", 

985 *, 

986 create: Literal[False], 

987 occupant_id: str | None, 

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

989 

990 @overload 

991 async def get_participant_by_contact( 

992 self, 

993 c: "LegacyContact[Any]", 

994 *, 

995 create: Literal[True], 

996 occupant_id: str | None, 

997 ) -> "LegacyParticipantType": ... 

998 

999 async def get_participant_by_contact( 

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

1001 ) -> "LegacyParticipantType | None": 

1002 """ 

1003 Get a non-anonymous participant. 

1004 

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

1006 that the Contact jid is associated to this participant 

1007 

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

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

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

1011 this participant. 

1012 :return: 

1013 """ 

1014 await self.session.contacts.ready 

1015 

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

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

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

1019 stored = ( 

1020 orm.query(Participant) 

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

1022 .one_or_none() 

1023 ) 

1024 if stored is None: 

1025 if occupant_id is not None: 

1026 stored = ( 

1027 orm.query(Participant) 

1028 .filter_by( 

1029 occupant_id=occupant_id, 

1030 room=self.stored, 

1031 contact_id=None, 

1032 ) 

1033 .one_or_none() 

1034 ) 

1035 if stored is not None: 

1036 self.log.debug( 

1037 "Updating the contact of a previously anonymous participant" 

1038 ) 

1039 stored.contact_id = c.stored.id 

1040 orm.add(stored) 

1041 orm.commit() 

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

1043 if not create: 

1044 return None 

1045 else: 

1046 if occupant_id and stored.occupant_id != occupant_id: 

1047 warnings.warn( 

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

1049 ) 

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

1051 

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

1053 

1054 if self.stored.id is None: 

1055 nick_available = True 

1056 else: 

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

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

1059 orm, self.stored.id, nickname 

1060 ) 

1061 

1062 if not nick_available: 

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

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

1065 p = self._participant_cls( 

1066 self, 

1067 Participant( 

1068 nickname=nickname, 

1069 room=self.stored, 

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

1071 ), 

1072 contact=c, 

1073 ) 

1074 

1075 self.__store_participant(p) 

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

1077 # during participants fill and history backfill we do not 

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

1079 # and role afterwards. 

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

1081 if ( 

1082 self.stored.participants_filled 

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

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

1085 ): 

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

1087 return p 

1088 

1089 @overload 

1090 async def get_participant_by_legacy_id( 

1091 self, legacy_id: LegacyUserIdType 

1092 ) -> "LegacyParticipantType": ... 

1093 

1094 @overload 

1095 async def get_participant_by_legacy_id( 

1096 self, 

1097 legacy_id: LegacyUserIdType, 

1098 *, 

1099 occupant_id: str | None, 

1100 create: Literal[True], 

1101 ) -> "LegacyParticipantType": ... 

1102 

1103 @overload 

1104 async def get_participant_by_legacy_id( 

1105 self, 

1106 legacy_id: LegacyUserIdType, 

1107 *, 

1108 occupant_id: str | None, 

1109 ) -> "LegacyParticipantType": ... 

1110 

1111 @overload 

1112 async def get_participant_by_legacy_id( 

1113 self, 

1114 legacy_id: LegacyUserIdType, 

1115 *, 

1116 occupant_id: str | None, 

1117 create: Literal[False], 

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

1119 

1120 async def get_participant_by_legacy_id( 

1121 self, 

1122 legacy_id: LegacyUserIdType, 

1123 *, 

1124 occupant_id: str | None = None, 

1125 create: bool = True, 

1126 ) -> "LegacyParticipantType": 

1127 try: 

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

1129 except ContactIsUser: 

1130 return await self.get_user_participant(occupant_id=occupant_id) 

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

1132 c, create=create, occupant_id=occupant_id 

1133 ) 

1134 

1135 def remove_participant( 

1136 self, 

1137 p: "LegacyParticipantType", 

1138 kick: bool = False, 

1139 ban: bool = False, 

1140 reason: str | None = None, 

1141 ) -> None: 

1142 """ 

1143 Call this when a participant leaves the room 

1144 

1145 :param p: The participant 

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

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

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

1149 """ 

1150 if kick and ban: 

1151 raise TypeError("Either kick or ban") 

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

1153 orm.delete(p.stored) 

1154 orm.commit() 

1155 if kick: 

1156 codes = {307} 

1157 elif ban: 

1158 codes = {301} 

1159 else: 

1160 codes = None 

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

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

1163 p.stored.role = "none" 

1164 if reason: 

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

1166 p._send(presence) 

1167 

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

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

1170 stored = ( 

1171 orm.query(Participant) 

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

1173 .one_or_none() 

1174 ) 

1175 if stored is None: 

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

1177 return 

1178 p = self.participant_from_store(stored) 

1179 if p.nickname == old_nickname: 

1180 p.nickname = new_nickname 

1181 

1182 async def __old_school_history( 

1183 self, 

1184 full_jid: JID, 

1185 maxchars: int | None = None, 

1186 maxstanzas: int | None = None, 

1187 seconds: int | None = None, 

1188 since: datetime | None = None, 

1189 ) -> None: 

1190 """ 

1191 Old-style history join (internal slidge use) 

1192 

1193 :param full_jid: 

1194 :param maxchars: 

1195 :param maxstanzas: 

1196 :param seconds: 

1197 :param since: 

1198 :return: 

1199 """ 

1200 if since is None: 

1201 if seconds is None: 

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

1203 else: 

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

1205 else: 

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

1207 

1208 for h_msg in self.archive.get_all( 

1209 start_date=start_date, end_date=None, last_page_n=maxstanzas 

1210 ): 

1211 msg = h_msg.stanza_component_ns 

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

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

1214 msg.set_to(full_jid) 

1215 self.xmpp.send(msg, False) 

1216 

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

1218 await self.__fill_history() 

1219 

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

1221 

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

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

1224 

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

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

1227 

1228 sender = form_values.get("with") 

1229 

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

1231 

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

1233 try: 

1234 max_results = int(max_str) 

1235 except ValueError: 

1236 max_results = None 

1237 else: 

1238 max_results = None 

1239 

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

1241 after_id = after_id_rsm or after_id 

1242 

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

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

1245 last_page_n = max_results 

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

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

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

1249 if before_rsm is not True: 

1250 before_id = before_rsm 

1251 else: 

1252 last_page_n = None 

1253 

1254 first = None 

1255 last = None 

1256 count = 0 

1257 

1258 it = self.archive.get_all( 

1259 start_date, 

1260 end_date, 

1261 before_id, 

1262 after_id, 

1263 ids, 

1264 last_page_n, 

1265 sender, 

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

1267 ) 

1268 

1269 for history_msg in it: 

1270 last = xmpp_id = history_msg.id 

1271 if first is None: 

1272 first = xmpp_id 

1273 

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

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

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

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

1278 

1279 wrapper_msg.send() 

1280 count += 1 

1281 

1282 if max_results and count == max_results: 

1283 break 

1284 

1285 if max_results: 

1286 try: 

1287 next(it) 

1288 except StopIteration: 

1289 complete = True 

1290 else: 

1291 complete = False 

1292 else: 

1293 complete = True 

1294 

1295 reply = iq.reply() 

1296 if not self.STABLE_ARCHIVE: 

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

1298 if complete: 

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

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

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

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

1303 reply.send() 

1304 

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

1306 await self.__fill_history() 

1307 await self.archive.send_metadata(iq) 

1308 

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

1310 """ 

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

1312 

1313 :param r: The resource to kick 

1314 """ 

1315 pto = JID(self.user_jid) 

1316 pto.resource = r 

1317 p = self.xmpp.make_presence( 

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

1319 ) 

1320 p["type"] = "unavailable" 

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

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

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

1324 p.send() 

1325 

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

1327 item = Item() 

1328 item["id"] = self.jid 

1329 

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

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

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

1333 

1334 try: 

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

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

1337 return None 

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

1339 # (slixmpp annoying magic) 

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

1341 item["id"] = self.jid 

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

1343 except IqTimeout: 

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

1345 return None 

1346 except IqError as exc: 

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

1348 return None 

1349 except PermissionError: 

1350 warnings.warn( 

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

1352 ) 

1353 return None 

1354 

1355 async def add_to_bookmarks( 

1356 self, 

1357 auto_join: bool = True, 

1358 preserve: bool = True, 

1359 pin: bool | None = None, 

1360 notify: WhenLiteral | None = None, 

1361 ) -> None: 

1362 """ 

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

1364 

1365 This requires that slidge has the IQ privileged set correctly 

1366 on the XMPP server 

1367 

1368 :param auto_join: whether XMPP clients should automatically join 

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

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

1371 join if they are online. 

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

1373 set by the user outside slidge 

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

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

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

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

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

1379 """ 

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

1381 

1382 new = Item() 

1383 new["id"] = self.jid 

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

1385 

1386 if existing is None: 

1387 change = True 

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

1389 else: 

1390 change = False 

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

1392 

1393 existing_extensions = existing is not None and existing[ 

1394 "conference" 

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

1396 

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

1398 if existing_extensions: 

1399 assert existing is not None 

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

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

1402 if notify is not None: 

1403 continue 

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

1405 if pin is not None: 

1406 continue 

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

1408 

1409 if pin is not None: 

1410 if existing_extensions: 

1411 assert existing is not None 

1412 existing_pin = ( 

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

1414 "pinned", check=True 

1415 ) 

1416 is not None 

1417 ) 

1418 if existing_pin != pin: 

1419 change = True 

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

1421 

1422 if notify is not None: 

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

1424 if existing_extensions: 

1425 assert existing is not None 

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

1427 "notify", check=True 

1428 ) 

1429 if existing_notify is None: 

1430 change = True 

1431 else: 

1432 if existing_notify.get_config() != notify: 

1433 change = True 

1434 for el in existing_notify: 

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

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

1437 

1438 if change: 

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

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

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

1442 

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

1444 

1445 try: 

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

1447 except PermissionError: 

1448 warnings.warn( 

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

1450 ) 

1451 # fallback by forcing invitation 

1452 bookmark_add_fail = True 

1453 except IqError as e: 

1454 warnings.warn( 

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

1456 ) 

1457 # fallback by forcing invitation 

1458 bookmark_add_fail = True 

1459 else: 

1460 bookmark_add_fail = False 

1461 else: 

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

1463 return 

1464 

1465 if bookmark_add_fail: 

1466 self.session.send_gateway_invite( 

1467 self, 

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

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

1470 "Contact your administrator.", 

1471 ) 

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

1473 "always_invite_when_adding_bookmarks", True 

1474 ): 

1475 self.session.send_gateway_invite( 

1476 self, 

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

1478 ) 

1479 

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

1481 """ 

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

1483 client. 

1484 

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

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

1487 updated on the XMPP side. 

1488 

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

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

1491 room update event. 

1492 

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

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

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

1496 correct. 

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

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

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

1500 """ 

1501 raise NotImplementedError 

1502 

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

1504 

1505 async def on_set_affiliation( 

1506 self, 

1507 contact: AnyContact, 

1508 affiliation: MucAffiliation, 

1509 reason: str | None, 

1510 nickname: str | None, 

1511 ) -> None: 

1512 """ 

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

1514 for this group. 

1515 

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

1517 

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

1519 :param affiliation: The new affiliation 

1520 :param reason: A reason for this affiliation change 

1521 :param nickname: 

1522 """ 

1523 raise NotImplementedError 

1524 

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

1526 """ 

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

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

1529 

1530 :param contact: Contact to be kicked 

1531 :param reason: A reason for this kick 

1532 """ 

1533 raise NotImplementedError 

1534 

1535 async def on_set_config( 

1536 self, 

1537 name: str | None, 

1538 description: str | None, 

1539 ) -> None: 

1540 """ 

1541 Triggered when the user requests changing the room configuration. 

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

1543 

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

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

1546 

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

1548 be ``None``. 

1549 

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

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

1552 """ 

1553 raise NotImplementedError 

1554 

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

1556 """ 

1557 Triggered when the user requests room destruction. 

1558 

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

1560 """ 

1561 raise NotImplementedError 

1562 

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

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

1565 await self.__fill_participants() 

1566 orm.add(self.stored) 

1567 participants = { 

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

1569 } 

1570 

1571 if len(participants) == 0: 

1572 return [] 

1573 

1574 result = [] 

1575 for match in re.finditer( 

1576 "|".join( 

1577 sorted( 

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

1579 key=lambda nick: len(nick), 

1580 reverse=True, 

1581 ) 

1582 ), 

1583 text, 

1584 ): 

1585 span = match.span() 

1586 nick = match.group() 

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

1588 continue 

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

1590 participant = self.participant_from_store( 

1591 stored=participants[nick], 

1592 ) 

1593 if contact := participant.contact: 

1594 result.append( 

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

1596 ) 

1597 return result 

1598 

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

1600 """ 

1601 Triggered when the user requests changing the room subject. 

1602 

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

1604 instance. 

1605 

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

1607 """ 

1608 raise NotImplementedError 

1609 

1610 async def on_set_thread_subject( 

1611 self, thread: LegacyThreadType, subject: str 

1612 ) -> None: 

1613 """ 

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

1615 

1616 :param thread: Legacy identifier of the thread 

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

1618 """ 

1619 raise NotImplementedError 

1620 

1621 @property 

1622 def participants_filled(self) -> bool: 

1623 return self.stored.participants_filled 

1624 

1625 def get_archived_messages( 

1626 self, msg_id: LegacyMessageType | str 

1627 ) -> Iterator[HistoryMessage]: 

1628 """ 

1629 Query the slidge archive for messages sent in this group 

1630 

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

1632 or an XMPP ID. 

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

1634 because of multi-attachment messages. 

1635 """ 

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

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

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

1639 ): 

1640 yield HistoryMessage(stored.stanza) 

1641 

1642 

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

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

1645 sub.attrib["id"] = origin_id 

1646 msg.xml.append(sub) 

1647 

1648 

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

1650 try: 

1651 return int(x) 

1652 except ValueError: 

1653 return None 

1654 

1655 

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

1657 if x is None: 

1658 return False 

1659 else: 

1660 return x == 0 

1661 

1662 

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

1664 if date is None: 

1665 return None 

1666 try: 

1667 return str_to_datetime(date) 

1668 except ValueError: 

1669 return None 

1670 

1671 

1672def bookmarks_form() -> Form: 

1673 form = Form() 

1674 form["type"] = "submit" 

1675 form.add_field( 

1676 "FORM_TYPE", 

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

1678 ftype="hidden", 

1679 ) 

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

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

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

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

1684 return form 

1685 

1686 

1687_BOOKMARKS_OPTIONS = bookmarks_form() 

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

1689 

1690log = logging.getLogger(__name__)