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

787 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-01-06 15:18 +0000

1import json 

2import logging 

3import re 

4import string 

5import uuid 

6import warnings 

7from asyncio import Lock 

8from contextlib import asynccontextmanager 

9from copy import copy 

10from datetime import datetime, timedelta, timezone 

11from typing import ( 

12 TYPE_CHECKING, 

13 Any, 

14 AsyncIterator, 

15 Generic, 

16 Iterator, 

17 Literal, 

18 Optional, 

19 Type, 

20 Union, 

21 overload, 

22) 

23 

24import sqlalchemy as sa 

25from slixmpp import JID, Iq, Message, Presence 

26from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

27from slixmpp.plugins.xep_0004 import Form 

28from slixmpp.plugins.xep_0060.stanza import Item 

29from slixmpp.plugins.xep_0082 import parse as str_to_datetime 

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

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

32from slixmpp.plugins.xep_0492.stanza import WhenLiteral 

33from slixmpp.xmlstream import ET 

34from sqlalchemy.exc import IntegrityError 

35from sqlalchemy.orm import Session as OrmSession 

36 

37from ..contact.contact import LegacyContact 

38from ..contact.roster import ContactIsUser 

39from ..core.mixins.avatar import AvatarMixin 

40from ..core.mixins.disco import ChatterDiscoMixin 

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

42from ..db.models import Participant, Room 

43from ..util.archive_msg import HistoryMessage 

44from ..util.jid_escaping import unescape_node 

45from ..util.types import ( 

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 ..core.session import BaseSession 

62 

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

64 

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

66 

67 

68class LegacyMUC( 

69 Generic[ 

70 LegacyGroupIdType, LegacyMessageType, LegacyParticipantType, LegacyUserIdType 

71 ], 

72 AvatarMixin, 

73 ChatterDiscoMixin, 

74 ReactionRecipientMixin, 

75 ThreadRecipientMixin, 

76 metaclass=SubclassableOnce, 

77): 

78 """ 

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

80 

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

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

83 """ 

84 

85 max_history_fetch = 100 

86 

87 is_group = True 

88 

89 DISCO_TYPE = "text" 

90 DISCO_CATEGORY = "conference" 

91 

92 STABLE_ARCHIVE = False 

93 """ 

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

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

96 across restarts. 

97 

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

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

100 

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

102 """ 

103 

104 _ALL_INFO_FILLED_ON_STARTUP = False 

105 """ 

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

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

108 """ 

109 

110 HAS_DESCRIPTION = True 

111 """ 

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

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

114 room configuration form. 

115 """ 

116 

117 HAS_SUBJECT = True 

118 """ 

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

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

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

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

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

124 tries to set the room subject. 

125 """ 

126 

127 archive: MessageArchive 

128 session: "BaseSession" 

129 

130 stored: Room 

131 

132 _participant_cls: Type[LegacyParticipantType] 

133 

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

135 self.session = session 

136 self.xmpp = session.xmpp 

137 self.stored = stored 

138 self._set_logger() 

139 super().__init__() 

140 

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

142 

143 if self._ALL_INFO_FILLED_ON_STARTUP: 

144 self.stored.participants_filled = True 

145 

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

147 """ 

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

149 

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

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

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

153 

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

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

156 MUC will be marked as read. 

157 

158 :param horizon_xmpp_id: The latest message 

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

160 """ 

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

162 assert self.stored.id is not None 

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

164 orm, self.stored.id, horizon_xmpp_id 

165 ) 

166 orm.commit() 

167 return ids 

168 

169 def participant_from_store( 

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

171 ) -> LegacyParticipantType: 

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

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

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

175 

176 @property 

177 def jid(self) -> JID: 

178 return self.stored.jid 

179 

180 @jid.setter 

181 def jid(self, x: JID): 

182 # FIXME: without this, mypy yields 

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

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

185 raise RuntimeError 

186 

187 @property 

188 def legacy_id(self): 

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

190 

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

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

193 

194 @property 

195 def type(self) -> MucType: 

196 return self.stored.muc_type 

197 

198 @type.setter 

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

200 if self.type == type_: 

201 return 

202 self.update_stored_attribute(muc_type=type_) 

203 

204 @property 

205 def n_participants(self): 

206 return self.stored.n_participants 

207 

208 @n_participants.setter 

209 def n_participants(self, n_participants: Optional[int]) -> None: 

210 if self.stored.n_participants == n_participants: 

211 return 

212 self.update_stored_attribute(n_participants=n_participants) 

213 

214 @property 

215 def user_jid(self): 

216 return self.session.user_jid 

217 

218 def _set_logger(self) -> None: 

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

220 

221 def __repr__(self) -> str: 

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

223 

224 @property 

225 def subject_date(self) -> Optional[datetime]: 

226 if self.stored.subject_date is None: 

227 return None 

228 return self.stored.subject_date.replace(tzinfo=timezone.utc) 

229 

230 @subject_date.setter 

231 def subject_date(self, when: Optional[datetime]) -> None: 

232 if self.subject_date == when: 

233 return 

234 self.update_stored_attribute(subject_date=when) 

235 

236 def __send_configuration_change(self, codes) -> None: 

237 part = self.get_system_participant() 

238 part.send_configuration_change(codes) 

239 

240 @property 

241 def user_nick(self): 

242 return ( 

243 self.stored.user_nick 

244 or self.session.bookmarks.user_nick 

245 or self.user_jid.node 

246 ) 

247 

248 @user_nick.setter 

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

250 if nick == self.user_nick: 

251 return 

252 self.update_stored_attribute(user_nick=nick) 

253 

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

255 stored_set = self.get_user_resources() 

256 if resource in stored_set: 

257 return 

258 stored_set.add(resource) 

259 self.update_stored_attribute( 

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

261 ) 

262 

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

264 stored_str = self.stored.user_resources 

265 if stored_str is None: 

266 return set() 

267 return set(json.loads(stored_str)) 

268 

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

270 stored_set = self.get_user_resources() 

271 if resource not in stored_set: 

272 return 

273 stored_set.remove(resource) 

274 self.update_stored_attribute( 

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

276 ) 

277 

278 @asynccontextmanager 

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

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

281 yield 

282 

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

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

285 

286 async def __fill_participants(self) -> None: 

287 if self._ALL_INFO_FILLED_ON_STARTUP or self.participants_filled: 

288 return 

289 

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

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

292 orm.add(self.stored) 

293 with orm.no_autoflush: 

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

295 if self.participants_filled: 

296 return 

297 parts: list[Participant] = [] 

298 resources = set[str]() 

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

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

301 async for participant in self.fill_participants(): 

302 if participant.stored.resource in resources: 

303 self.log.debug( 

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

305 participant.stored.resource, 

306 ) 

307 continue 

308 parts.append(participant.stored) 

309 resources.add(participant.stored.resource) 

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

311 orm.add(self.stored) 

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

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

314 # and the participant_filled attribute. 

315 with orm.no_autoflush: 

316 orm.refresh(self.stored) 

317 for part in parts: 

318 orm.merge(part) 

319 self.stored.participants_filled = True 

320 orm.commit() 

321 

322 async def get_participants( 

323 self, affiliation: Optional[MucAffiliation] = None 

324 ) -> AsyncIterator[LegacyParticipantType]: 

325 await self.__fill_participants() 

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

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

328 for db_participant in self.stored.participants: 

329 if ( 

330 affiliation is not None 

331 and db_participant.affiliation != affiliation 

332 ): 

333 continue 

334 yield self.participant_from_store(db_participant) 

335 

336 async def __fill_history(self) -> None: 

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

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

339 orm.add(self.stored) 

340 with orm.no_autoflush: 

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

342 if self.stored.history_filled: 

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

344 return 

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

346 try: 

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

348 if before is not None: 

349 before = before._replace( 

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

351 ) 

352 if after is not None: 

353 after = after._replace( 

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

355 ) 

356 await self.backfill(before, after) 

357 except NotImplementedError: 

358 return 

359 except Exception as e: 

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

361 

362 self.stored.history_filled = True 

363 self.commit(merge=True) 

364 

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

366 return self.name 

367 

368 @property 

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

370 return self.stored.name 

371 

372 @name.setter 

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

374 if self.name == n: 

375 return 

376 self.update_stored_attribute(name=n) 

377 self._set_logger() 

378 self.__send_configuration_change((104,)) 

379 

380 @property 

381 def description(self): 

382 return self.stored.description or "" 

383 

384 @description.setter 

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

386 if self.description == d: 

387 return 

388 self.update_stored_attribute(description=d) 

389 self.__send_configuration_change((104,)) 

390 

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

392 pto = p.get_to() 

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

394 return 

395 

396 pfrom = p.get_from() 

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

398 return 

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

400 if pto.resource != self.user_nick: 

401 self.log.debug( 

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

403 ) 

404 self.remove_user_resource(resource) 

405 else: 

406 self.log.debug( 

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

408 ) 

409 

410 async def update_info(self): 

411 """ 

412 Fetch information about this group from the legacy network 

413 

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

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

416 of participants etc. 

417 

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

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

420 is no change, you should not call 

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

422 attempt to modify 

423 the :attr:.avatar property. 

424 """ 

425 raise NotImplementedError 

426 

427 async def backfill( 

428 self, 

429 after: Optional[HoleBound] = None, 

430 before: Optional[HoleBound] = None, 

431 ): 

432 """ 

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

434 

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

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

437 run for a given group. 

438 

439 :param after: Fetch messages after this one. If ``None``, it's up to you 

440 to decide how far you want to go in the archive. If it's not ``None``, 

441 it means slidge has some messages in this archive and you should really try 

442 to complete it to avoid "holes" in the history of this group. 

443 :param before: Fetch messages before this one. If ``None``, fetch all messages 

444 up to the most recent one 

445 """ 

446 raise NotImplementedError 

447 

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

449 """ 

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

451 

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

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

454 before yielding them. 

455 """ 

456 return 

457 yield 

458 

459 @property 

460 def subject(self) -> str: 

461 return self.stored.subject or "" 

462 

463 @subject.setter 

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

465 if s == self.subject: 

466 return 

467 

468 self.update_stored_attribute(subject=s) 

469 self.__get_subject_setter_participant().set_room_subject( 

470 s, None, self.subject_date, False 

471 ) 

472 

473 @property 

474 def is_anonymous(self): 

475 return self.type == MucType.CHANNEL 

476 

477 @property 

478 def subject_setter(self) -> Optional[str]: 

479 return self.stored.subject_setter 

480 

481 @subject_setter.setter 

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

483 if isinstance(subject_setter, LegacyContact): 

484 subject_setter = subject_setter.name 

485 elif isinstance(subject_setter, LegacyParticipant): 

486 subject_setter = subject_setter.nickname 

487 

488 if subject_setter == self.subject_setter: 

489 return 

490 assert isinstance(subject_setter, str | None) 

491 self.update_stored_attribute(subject_setter=subject_setter) 

492 

493 def __get_subject_setter_participant(self) -> LegacyParticipant: 

494 if self.subject_setter is None: 

495 return self.get_system_participant() 

496 return self._participant_cls( 

497 self, 

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

499 ) 

500 

501 def features(self): 

502 features = [ 

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

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

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

506 "urn:xmpp:mam:2", 

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

508 "urn:xmpp:sid:0", 

509 "muc_persistent", 

510 "vcard-temp", 

511 "urn:xmpp:ping", 

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

513 "jabber:iq:register", 

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

515 ] 

516 if self.type == MucType.GROUP: 

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

518 elif self.type == MucType.CHANNEL: 

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

520 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

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

522 return features 

523 

524 async def extended_features(self): 

525 is_group = self.type == MucType.GROUP 

526 

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

528 

529 form.add_field( 

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

531 ) 

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

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

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

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

536 

537 if self._ALL_INFO_FILLED_ON_STARTUP or self.stored.participants_filled: 

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

539 n = orm.scalar( 

540 sa.select(sa.func.count(Participant.id)).filter_by(room=self.stored) 

541 ) 

542 else: 

543 n = self.n_participants 

544 if n is not None: 

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

546 

547 if d := self.description: 

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

549 

550 if s := self.subject: 

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

552 

553 if name := self.name: 

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

555 

556 if self._set_avatar_task is not None: 

557 await self._set_avatar_task 

558 avatar = self.get_avatar() 

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

560 form.add_field( 

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

562 ) 

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

564 

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

566 form.add_field( 

567 "muc#roomconfig_whois", 

568 "list-single", 

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

570 ) 

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

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

573 

574 r = [form] 

575 

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

577 r.append(reaction_form) 

578 

579 return r 

580 

581 def shutdown(self) -> None: 

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

583 for user_full_jid in self.user_full_jids(): 

584 presence = self.xmpp.make_presence( 

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

586 ) 

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

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

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

590 presence.send() 

591 

592 def user_full_jids(self): 

593 for r in self.get_user_resources(): 

594 j = JID(self.user_jid) 

595 j.resource = r 

596 yield j 

597 

598 @property 

599 def user_muc_jid(self): 

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

601 return user_muc_jid 

602 

603 async def echo( 

604 self, msg: Message, legacy_msg_id: Optional[LegacyMessageType] = None 

605 ) -> str: 

606 origin_id = msg.get_origin_id() 

607 

608 msg.set_from(self.user_muc_jid) 

609 msg.set_id(msg.get_id()) 

610 if origin_id: 

611 # because of slixmpp internal magic, we need to do this to ensure the origin_id 

612 # is present 

613 set_origin_id(msg, origin_id) 

614 if legacy_msg_id: 

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

616 else: 

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

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

619 

620 user_part = await self.get_user_participant() 

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

622 

623 self.archive.add(msg, user_part) 

624 

625 for user_full_jid in self.user_full_jids(): 

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

627 msg = copy(msg) 

628 msg.set_to(user_full_jid) 

629 

630 msg.send() 

631 

632 return msg["stanza_id"]["id"] 

633 

634 def _post_avatar_update(self, cached_avatar) -> None: 

635 self.__send_configuration_change((104,)) 

636 self._send_room_presence() 

637 

638 def _send_room_presence(self, user_full_jid: Optional[JID] = None) -> None: 

639 if user_full_jid is None: 

640 tos = self.user_full_jids() 

641 else: 

642 tos = [user_full_jid] 

643 for to in tos: 

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

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

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

647 else: 

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

649 p.send() 

650 

651 @timeit 

652 async def join(self, join_presence: Presence): 

653 user_full_jid = join_presence.get_from() 

654 requested_nickname = join_presence.get_to().resource 

655 client_resource = user_full_jid.resource 

656 

657 if client_resource in self.get_user_resources(): 

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

659 

660 if not requested_nickname or not client_resource: 

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

662 

663 self.add_user_resource(client_resource) 

664 

665 self.log.debug( 

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

667 client_resource, 

668 self.user_jid, 

669 self.legacy_id, 

670 requested_nickname, 

671 ) 

672 

673 user_nick = self.user_nick 

674 user_participant = None 

675 async for participant in self.get_participants(): 

676 if participant.is_user: 

677 user_participant = participant 

678 continue 

679 participant.send_initial_presence(full_jid=user_full_jid) 

680 

681 if user_participant is None: 

682 user_participant = await self.get_user_participant() 

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

684 orm.add(self.stored) 

685 with orm.no_autoflush: 

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

687 if not user_participant.is_user: 

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

689 user_participant.is_user = True 

690 user_participant.send_initial_presence( 

691 user_full_jid, 

692 presence_id=join_presence["id"], 

693 nick_change=user_nick != requested_nickname, 

694 ) 

695 

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

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

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

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

700 try: 

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

702 except ValueError: 

703 since = None 

704 if seconds is not None: 

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

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

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

708 else: 

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

710 await self.__fill_history() 

711 await self.__old_school_history( 

712 user_full_jid, 

713 maxchars=maxchars, 

714 maxstanzas=maxstanzas, 

715 since=since, 

716 ) 

717 if self.HAS_SUBJECT: 

718 subject = self.subject or "" 

719 else: 

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

721 self.__get_subject_setter_participant().set_room_subject( 

722 subject, 

723 user_full_jid, 

724 self.subject_date, 

725 ) 

726 if t := self._set_avatar_task: 

727 await t 

728 self._send_room_presence(user_full_jid) 

729 

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

731 """ 

732 Get the participant representing the gateway user 

733 

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

735 construction (optional) 

736 :return: 

737 """ 

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

739 self.__store_participant(p) 

740 return p 

741 

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

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

744 return 

745 try: 

746 p.commit(merge=True) 

747 except IntegrityError as e: 

748 if self._ALL_INFO_FILLED_ON_STARTUP: 

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

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

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

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

753 ) 

754 p.stored.room = self.stored 

755 orm.add(p.stored) 

756 orm.commit() 

757 else: 

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

759 

760 @overload 

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

762 

763 @overload 

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

765 

766 @overload 

767 async def get_participant( 

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

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

770 

771 @overload 

772 async def get_participant( 

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

774 ) -> "LegacyParticipantType": ... 

775 

776 @overload 

777 async def get_participant( 

778 self, nickname: str, *, occupant_id: str 

779 ) -> "LegacyParticipantType": ... 

780 

781 @overload 

782 async def get_participant( 

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

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

785 

786 @overload 

787 async def get_participant( 

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

789 ) -> "LegacyParticipantType": ... 

790 

791 @overload 

792 async def get_participant( 

793 self, 

794 nickname: str, 

795 *, 

796 create: Literal[True], 

797 is_user: bool, 

798 fill_first: bool, 

799 store: bool, 

800 ) -> "LegacyParticipantType": ... 

801 

802 @overload 

803 async def get_participant( 

804 self, 

805 nickname: str, 

806 *, 

807 create: Literal[False], 

808 is_user: bool, 

809 fill_first: bool, 

810 store: bool, 

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

812 

813 @overload 

814 async def get_participant( 

815 self, 

816 nickname: str, 

817 *, 

818 create: bool, 

819 fill_first: bool, 

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

821 

822 async def get_participant( 

823 self, 

824 nickname: str | None = None, 

825 *, 

826 create: bool = True, 

827 is_user: bool = False, 

828 fill_first: bool = False, 

829 store: bool = True, 

830 occupant_id: str | None = None, 

831 ) -> "LegacyParticipantType | None": 

832 """ 

833 Get a participant by their nickname. 

834 

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

836 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

837 

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

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

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

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

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

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

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

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

846 xep:`0421` 

847 :return: A participant of this room. 

848 """ 

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

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

851 if fill_first: 

852 await self.__fill_participants() 

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

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

855 if occupant_id is not None: 

856 stored = ( 

857 orm.query(Participant) 

858 .filter( 

859 Participant.room == self.stored, 

860 Participant.occupant_id == occupant_id, 

861 ) 

862 .one_or_none() 

863 ) 

864 elif nickname is not None: 

865 stored = ( 

866 orm.query(Participant) 

867 .filter( 

868 Participant.room == self.stored, 

869 (Participant.nickname == nickname) 

870 | (Participant.resource == nickname), 

871 ) 

872 .one_or_none() 

873 ) 

874 else: 

875 raise RuntimeError("NEVER") 

876 if stored is not None: 

877 if occupant_id and occupant_id != stored.occupant_id: 

878 warnings.warn( 

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

880 ) 

881 part = self.participant_from_store(stored) 

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

883 stored.nickname = nickname 

884 orm.add(stored) 

885 orm.commit() 

886 return part 

887 

888 if not create: 

889 return None 

890 

891 if occupant_id is None: 

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

893 

894 if nickname is None: 

895 nickname = occupant_id 

896 

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

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

899 

900 p = self._participant_cls( 

901 self, 

902 Participant( 

903 room=self.stored, 

904 nickname=nickname or occupant_id, 

905 is_user=is_user, 

906 occupant_id=occupant_id, 

907 ), 

908 ) 

909 if store: 

910 self.__store_participant(p) 

911 if ( 

912 not self.get_lock("fill participants") 

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

914 and self.stored.participants_filled 

915 and not p.is_user 

916 and not p.is_system 

917 ): 

918 p.send_affiliation_change() 

919 return p 

920 

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

922 """ 

923 Get a pseudo-participant, representing the room itself 

924 

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

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

927 service 

928 :return: 

929 """ 

930 return self._participant_cls( 

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

932 ) 

933 

934 @overload 

935 async def get_participant_by_contact( 

936 self, c: "LegacyContact[Any]" 

937 ) -> "LegacyParticipantType": ... 

938 

939 @overload 

940 async def get_participant_by_contact( 

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

942 ) -> "LegacyParticipantType": ... 

943 

944 @overload 

945 async def get_participant_by_contact( 

946 self, 

947 c: "LegacyContact[Any]", 

948 *, 

949 create: Literal[False], 

950 occupant_id: str | None, 

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

952 

953 @overload 

954 async def get_participant_by_contact( 

955 self, 

956 c: "LegacyContact[Any]", 

957 *, 

958 create: Literal[True], 

959 occupant_id: str | None, 

960 ) -> "LegacyParticipantType": ... 

961 

962 async def get_participant_by_contact( 

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

964 ) -> "LegacyParticipantType | None": 

965 """ 

966 Get a non-anonymous participant. 

967 

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

969 that the Contact jid is associated to this participant 

970 

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

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

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

974 this participant. 

975 :return: 

976 """ 

977 await self.session.contacts.ready 

978 

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

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

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

982 stored = ( 

983 orm.query(Participant) 

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

985 .one_or_none() 

986 ) 

987 if stored is None: 

988 if not create: 

989 return None 

990 else: 

991 if occupant_id and stored.occupant_id != occupant_id: 

992 warnings.warn( 

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

994 ) 

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

996 

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

998 

999 if self.stored.id is None: 

1000 nick_available = True 

1001 else: 

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

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

1004 orm, self.stored.id, nickname 

1005 ) 

1006 

1007 if not nick_available: 

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

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

1010 p = self._participant_cls( 

1011 self, 

1012 Participant( 

1013 nickname=nickname, 

1014 room=self.stored, 

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

1016 ), 

1017 contact=c, 

1018 ) 

1019 

1020 self.__store_participant(p) 

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

1022 # during participants fill and history backfill we do not 

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

1024 # and role afterwards. 

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

1026 if ( 

1027 self.stored.participants_filled 

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

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

1030 ): 

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

1032 return p 

1033 

1034 @overload 

1035 async def get_participant_by_legacy_id( 

1036 self, legacy_id: LegacyUserIdType 

1037 ) -> "LegacyParticipantType": ... 

1038 

1039 @overload 

1040 async def get_participant_by_legacy_id( 

1041 self, 

1042 legacy_id: LegacyUserIdType, 

1043 *, 

1044 occupant_id: str | None, 

1045 create: Literal[True], 

1046 ) -> "LegacyParticipantType": ... 

1047 

1048 @overload 

1049 async def get_participant_by_legacy_id( 

1050 self, 

1051 legacy_id: LegacyUserIdType, 

1052 *, 

1053 occupant_id: str | None, 

1054 create: Literal[False], 

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

1056 

1057 async def get_participant_by_legacy_id( 

1058 self, 

1059 legacy_id: LegacyUserIdType, 

1060 *, 

1061 occupant_id: str | None = None, 

1062 create: bool = True, 

1063 ) -> "LegacyParticipantType": 

1064 try: 

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

1066 except ContactIsUser: 

1067 return await self.get_user_participant(occupant_id=occupant_id) 

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

1069 c, create=create, occupant_id=occupant_id 

1070 ) 

1071 

1072 def remove_participant( 

1073 self, 

1074 p: "LegacyParticipantType", 

1075 kick: bool = False, 

1076 ban: bool = False, 

1077 reason: str | None = None, 

1078 ): 

1079 """ 

1080 Call this when a participant leaves the room 

1081 

1082 :param p: The participant 

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

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

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

1086 """ 

1087 if kick and ban: 

1088 raise TypeError("Either kick or ban") 

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

1090 orm.delete(p.stored) 

1091 orm.commit() 

1092 if kick: 

1093 codes = {307} 

1094 elif ban: 

1095 codes = {301} 

1096 else: 

1097 codes = None 

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

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

1100 p.stored.role = "none" 

1101 if reason: 

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

1103 p._send(presence) 

1104 

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

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

1107 stored = ( 

1108 orm.query(Participant) 

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

1110 .one_or_none() 

1111 ) 

1112 if stored is None: 

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

1114 return 

1115 p = self.participant_from_store(stored) 

1116 if p.nickname == old_nickname: 

1117 p.nickname = new_nickname 

1118 

1119 async def __old_school_history( 

1120 self, 

1121 full_jid: JID, 

1122 maxchars: Optional[int] = None, 

1123 maxstanzas: Optional[int] = None, 

1124 seconds: Optional[int] = None, 

1125 since: Optional[datetime] = None, 

1126 ) -> None: 

1127 """ 

1128 Old-style history join (internal slidge use) 

1129 

1130 :param full_jid: 

1131 :param maxchars: 

1132 :param maxstanzas: 

1133 :param seconds: 

1134 :param since: 

1135 :return: 

1136 """ 

1137 if since is None: 

1138 if seconds is None: 

1139 start_date = datetime.now(tz=timezone.utc) - timedelta(days=1) 

1140 else: 

1141 start_date = datetime.now(tz=timezone.utc) - timedelta(seconds=seconds) 

1142 else: 

1143 start_date = since or datetime.now(tz=timezone.utc) - timedelta(days=1) 

1144 

1145 for h_msg in self.archive.get_all( 

1146 start_date=start_date, end_date=None, last_page_n=maxstanzas 

1147 ): 

1148 msg = h_msg.stanza_component_ns 

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

1150 msg.set_to(full_jid) 

1151 self.xmpp.send(msg, False) 

1152 

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

1154 await self.__fill_history() 

1155 

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

1157 

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

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

1160 

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

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

1163 

1164 sender = form_values.get("with") 

1165 

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

1167 

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

1169 try: 

1170 max_results = int(max_str) 

1171 except ValueError: 

1172 max_results = None 

1173 else: 

1174 max_results = None 

1175 

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

1177 after_id = after_id_rsm or after_id 

1178 

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

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

1181 last_page_n = max_results 

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

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

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

1185 if before_rsm is not True: 

1186 before_id = before_rsm 

1187 else: 

1188 last_page_n = None 

1189 

1190 first = None 

1191 last = None 

1192 count = 0 

1193 

1194 it = self.archive.get_all( 

1195 start_date, 

1196 end_date, 

1197 before_id, 

1198 after_id, 

1199 ids, 

1200 last_page_n, 

1201 sender, 

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

1203 ) 

1204 

1205 for history_msg in it: 

1206 last = xmpp_id = history_msg.id 

1207 if first is None: 

1208 first = xmpp_id 

1209 

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

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

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

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

1214 

1215 wrapper_msg.send() 

1216 count += 1 

1217 

1218 if max_results and count == max_results: 

1219 break 

1220 

1221 if max_results: 

1222 try: 

1223 next(it) 

1224 except StopIteration: 

1225 complete = True 

1226 else: 

1227 complete = False 

1228 else: 

1229 complete = True 

1230 

1231 reply = iq.reply() 

1232 if not self.STABLE_ARCHIVE: 

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

1234 if complete: 

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

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

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

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

1239 reply.send() 

1240 

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

1242 await self.__fill_history() 

1243 await self.archive.send_metadata(iq) 

1244 

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

1246 """ 

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

1248 

1249 :param r: The resource to kick 

1250 """ 

1251 pto = JID(self.user_jid) 

1252 pto.resource = r 

1253 p = self.xmpp.make_presence( 

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

1255 ) 

1256 p["type"] = "unavailable" 

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

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

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

1260 p.send() 

1261 

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

1263 item = Item() 

1264 item["id"] = self.jid 

1265 

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

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

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

1269 

1270 try: 

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

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

1273 return None 

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

1275 # (slixmpp annoying magic) 

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

1277 item["id"] = self.jid 

1278 return item 

1279 except IqTimeout: 

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

1281 return None 

1282 except IqError as exc: 

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

1284 return None 

1285 except PermissionError: 

1286 warnings.warn( 

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

1288 ) 

1289 return None 

1290 

1291 async def add_to_bookmarks( 

1292 self, 

1293 auto_join: bool = True, 

1294 preserve: bool = True, 

1295 pin: bool | None = None, 

1296 notify: WhenLiteral | None = None, 

1297 ) -> None: 

1298 """ 

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

1300 

1301 This requires that slidge has the IQ privileged set correctly 

1302 on the XMPP server 

1303 

1304 :param auto_join: whether XMPP clients should automatically join 

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

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

1307 join if they are online. 

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

1309 set by the user outside slidge 

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

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

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

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

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

1315 """ 

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

1317 

1318 new = Item() 

1319 new["id"] = self.jid 

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

1321 

1322 if existing is None: 

1323 change = True 

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

1325 else: 

1326 change = False 

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

1328 

1329 existing_extensions = existing is not None and existing[ 

1330 "conference" 

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

1332 

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

1334 if existing_extensions: 

1335 assert existing is not None 

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

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

1338 if notify is not None: 

1339 continue 

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

1341 if pin is not None: 

1342 continue 

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

1344 

1345 if pin is not None: 

1346 if existing_extensions: 

1347 assert existing is not None 

1348 existing_pin = ( 

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

1350 "pinned", check=True 

1351 ) 

1352 is not None 

1353 ) 

1354 if existing_pin != pin: 

1355 change = True 

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

1357 

1358 if notify is not None: 

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

1360 if existing_extensions: 

1361 assert existing is not None 

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

1363 "notify", check=True 

1364 ) 

1365 if existing_notify is None: 

1366 change = True 

1367 else: 

1368 if existing_notify.get_config() != notify: 

1369 change = True 

1370 for el in existing_notify: 

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

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

1373 

1374 if change: 

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

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

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

1378 

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

1380 

1381 try: 

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

1383 except PermissionError: 

1384 warnings.warn( 

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

1386 ) 

1387 # fallback by forcing invitation 

1388 bookmark_add_fail = True 

1389 except IqError as e: 

1390 warnings.warn( 

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

1392 ) 

1393 # fallback by forcing invitation 

1394 bookmark_add_fail = True 

1395 else: 

1396 bookmark_add_fail = False 

1397 else: 

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

1399 return 

1400 

1401 if bookmark_add_fail: 

1402 self.session.send_gateway_invite( 

1403 self, 

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

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

1406 "Contact your administrator.", 

1407 ) 

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

1409 "always_invite_when_adding_bookmarks", True 

1410 ): 

1411 self.session.send_gateway_invite( 

1412 self, 

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

1414 ) 

1415 

1416 async def on_avatar( 

1417 self, data: Optional[bytes], mime: Optional[str] 

1418 ) -> Optional[Union[int, str]]: 

1419 """ 

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

1421 client. 

1422 

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

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

1425 updated on the XMPP side. 

1426 

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

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

1429 room update event. 

1430 

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

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

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

1434 correct. 

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

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

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

1438 """ 

1439 raise NotImplementedError 

1440 

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

1442 

1443 async def on_set_affiliation( 

1444 self, 

1445 contact: "LegacyContact", 

1446 affiliation: MucAffiliation, 

1447 reason: Optional[str], 

1448 nickname: Optional[str], 

1449 ): 

1450 """ 

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

1452 for this group. 

1453 

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

1455 

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

1457 :param affiliation: The new affiliation 

1458 :param reason: A reason for this affiliation change 

1459 :param nickname: 

1460 """ 

1461 raise NotImplementedError 

1462 

1463 async def on_kick(self, contact: "LegacyContact", reason: Optional[str]): 

1464 """ 

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

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

1467 

1468 :param contact: Contact to be kicked 

1469 :param reason: A reason for this kick 

1470 """ 

1471 raise NotImplementedError 

1472 

1473 async def on_set_config( 

1474 self, 

1475 name: Optional[str], 

1476 description: Optional[str], 

1477 ): 

1478 """ 

1479 Triggered when the user requests changing the room configuration. 

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

1481 

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

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

1484 

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

1486 be ``None``. 

1487 

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

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

1490 """ 

1491 raise NotImplementedError 

1492 

1493 async def on_destroy_request(self, reason: Optional[str]): 

1494 """ 

1495 Triggered when the user requests room destruction. 

1496 

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

1498 """ 

1499 raise NotImplementedError 

1500 

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

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

1503 await self.__fill_participants() 

1504 orm.add(self.stored) 

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

1506 

1507 if len(participants) == 0: 

1508 return [] 

1509 

1510 result = [] 

1511 for match in re.finditer( 

1512 "|".join( 

1513 sorted( 

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

1515 key=lambda nick: len(nick), 

1516 reverse=True, 

1517 ) 

1518 ), 

1519 text, 

1520 ): 

1521 span = match.span() 

1522 nick = match.group() 

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

1524 continue 

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

1526 participant = self.participant_from_store( 

1527 stored=participants[nick], 

1528 ) 

1529 if contact := participant.contact: 

1530 result.append( 

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

1532 ) 

1533 return result 

1534 

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

1536 """ 

1537 Triggered when the user requests changing the room subject. 

1538 

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

1540 instance. 

1541 

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

1543 """ 

1544 raise NotImplementedError 

1545 

1546 async def on_set_thread_subject( 

1547 self, thread: LegacyThreadType, subject: str 

1548 ) -> None: 

1549 """ 

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

1551 

1552 :param thread: Legacy identifier of the thread 

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

1554 """ 

1555 raise NotImplementedError 

1556 

1557 @property 

1558 def participants_filled(self) -> bool: 

1559 return self.stored.participants_filled 

1560 

1561 def get_archived_messages( 

1562 self, msg_id: LegacyMessageType | str 

1563 ) -> Iterator[HistoryMessage]: 

1564 """ 

1565 Query the slidge archive for messages sent in this group 

1566 

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

1568 or an XMPP ID. 

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

1570 because of multi-attachment messages. 

1571 """ 

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

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

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

1575 ): 

1576 yield HistoryMessage(stored.stanza) 

1577 

1578 

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

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

1581 sub.attrib["id"] = origin_id 

1582 msg.xml.append(sub) 

1583 

1584 

1585def int_or_none(x): 

1586 try: 

1587 return int(x) 

1588 except ValueError: 

1589 return None 

1590 

1591 

1592def equals_zero(x): 

1593 if x is None: 

1594 return False 

1595 else: 

1596 return x == 0 

1597 

1598 

1599def str_to_datetime_or_none(date: Optional[str]): 

1600 if date is None: 

1601 return 

1602 try: 

1603 return str_to_datetime(date) 

1604 except ValueError: 

1605 return None 

1606 

1607 

1608def bookmarks_form(): 

1609 form = Form() 

1610 form["type"] = "submit" 

1611 form.add_field( 

1612 "FORM_TYPE", 

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

1614 ftype="hidden", 

1615 ) 

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

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

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

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

1620 return form 

1621 

1622 

1623_BOOKMARKS_OPTIONS = bookmarks_form() 

1624_WHITESPACE_OR_PUNCTUATION = string.whitespace + string.punctuation 

1625 

1626log = logging.getLogger(__name__)