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

786 statements  

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

1import json 

2import logging 

3import re 

4import string 

5import uuid 

6import warnings 

7from asyncio import Lock 

8from collections.abc import AsyncIterator, Iterator 

9from contextlib import asynccontextmanager 

10from copy import copy 

11from datetime import UTC, datetime, timedelta 

12from typing import ( 

13 TYPE_CHECKING, 

14 Any, 

15 Generic, 

16 Literal, 

17 Union, 

18 overload, 

19) 

20 

21import sqlalchemy as sa 

22from slixmpp import JID, Iq, Message, Presence 

23from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

24from slixmpp.plugins.xep_0004 import Form 

25from slixmpp.plugins.xep_0060.stanza import Item 

26from slixmpp.plugins.xep_0082 import parse as str_to_datetime 

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

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

29from slixmpp.plugins.xep_0492.stanza import WhenLiteral 

30from slixmpp.xmlstream import ET 

31from sqlalchemy.exc import IntegrityError 

32from sqlalchemy.orm import Session as OrmSession 

33 

34from ..contact.contact import LegacyContact 

35from ..contact.roster import ContactIsUser 

36from ..core.mixins.avatar import AvatarMixin 

37from ..core.mixins.disco import ChatterDiscoMixin 

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

39from ..db.models import Participant, Room 

40from ..util.archive_msg import HistoryMessage 

41from ..util.jid_escaping import unescape_node 

42from ..util.types import ( 

43 HoleBound, 

44 LegacyGroupIdType, 

45 LegacyMessageType, 

46 LegacyParticipantType, 

47 LegacyThreadType, 

48 LegacyUserIdType, 

49 Mention, 

50 MucAffiliation, 

51 MucType, 

52) 

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

54from .archive import MessageArchive 

55from .participant import LegacyParticipant, escape_nickname 

56 

57if TYPE_CHECKING: 

58 from ..core.session import BaseSession 

59 

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

61 

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

63 

64 

65class LegacyMUC( 

66 Generic[ 

67 LegacyGroupIdType, LegacyMessageType, LegacyParticipantType, LegacyUserIdType 

68 ], 

69 AvatarMixin, 

70 ChatterDiscoMixin, 

71 ReactionRecipientMixin, 

72 ThreadRecipientMixin, 

73 metaclass=SubclassableOnce, 

74): 

75 """ 

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

77 

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

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

80 """ 

81 

82 max_history_fetch = 100 

83 

84 is_group = True 

85 

86 DISCO_TYPE = "text" 

87 DISCO_CATEGORY = "conference" 

88 

89 STABLE_ARCHIVE = False 

90 """ 

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

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

93 across restarts. 

94 

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

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

97 

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

99 """ 

100 

101 _ALL_INFO_FILLED_ON_STARTUP = False 

102 """ 

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

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

105 """ 

106 

107 HAS_DESCRIPTION = True 

108 """ 

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

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

111 room configuration form. 

112 """ 

113 

114 HAS_SUBJECT = True 

115 """ 

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

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

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

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

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

121 tries to set the room subject. 

122 """ 

123 

124 archive: MessageArchive 

125 session: "BaseSession" 

126 

127 stored: Room 

128 

129 _participant_cls: type[LegacyParticipantType] 

130 

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

132 self.session = session 

133 self.xmpp = session.xmpp 

134 self.stored = stored 

135 self._set_logger() 

136 super().__init__() 

137 

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

139 

140 if self._ALL_INFO_FILLED_ON_STARTUP: 

141 self.stored.participants_filled = True 

142 

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

144 """ 

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

146 

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

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

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

150 

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

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

153 MUC will be marked as read. 

154 

155 :param horizon_xmpp_id: The latest message 

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

157 """ 

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

159 assert self.stored.id is not None 

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

161 orm, self.stored.id, horizon_xmpp_id 

162 ) 

163 orm.commit() 

164 return ids 

165 

166 def participant_from_store( 

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

168 ) -> LegacyParticipantType: 

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

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

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

172 

173 @property 

174 def jid(self) -> JID: 

175 return self.stored.jid 

176 

177 @jid.setter 

178 def jid(self, x: JID): 

179 # FIXME: without this, mypy yields 

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

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

182 raise RuntimeError 

183 

184 @property 

185 def legacy_id(self): 

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

187 

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

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

190 

191 @property 

192 def type(self) -> MucType: 

193 return self.stored.muc_type 

194 

195 @type.setter 

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

197 if self.type == type_: 

198 return 

199 self.update_stored_attribute(muc_type=type_) 

200 

201 @property 

202 def n_participants(self): 

203 return self.stored.n_participants 

204 

205 @n_participants.setter 

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

207 if self.stored.n_participants == n_participants: 

208 return 

209 self.update_stored_attribute(n_participants=n_participants) 

210 

211 @property 

212 def user_jid(self): 

213 return self.session.user_jid 

214 

215 def _set_logger(self) -> None: 

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

217 

218 def __repr__(self) -> str: 

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

220 

221 @property 

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

223 if self.stored.subject_date is None: 

224 return None 

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

226 

227 @subject_date.setter 

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

229 if self.subject_date == when: 

230 return 

231 self.update_stored_attribute(subject_date=when) 

232 

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

234 part = self.get_system_participant() 

235 part.send_configuration_change(codes) 

236 

237 @property 

238 def user_nick(self): 

239 return ( 

240 self.stored.user_nick 

241 or self.session.bookmarks.user_nick 

242 or self.user_jid.node 

243 ) 

244 

245 @user_nick.setter 

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

247 if nick == self.user_nick: 

248 return 

249 self.update_stored_attribute(user_nick=nick) 

250 

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

252 stored_set = self.get_user_resources() 

253 if resource in stored_set: 

254 return 

255 stored_set.add(resource) 

256 self.update_stored_attribute( 

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

258 ) 

259 

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

261 stored_str = self.stored.user_resources 

262 if stored_str is None: 

263 return set() 

264 return set(json.loads(stored_str)) 

265 

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

267 stored_set = self.get_user_resources() 

268 if resource not in stored_set: 

269 return 

270 stored_set.remove(resource) 

271 self.update_stored_attribute( 

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

273 ) 

274 

275 @asynccontextmanager 

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

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

278 yield 

279 

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

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

282 

283 async def __fill_participants(self) -> None: 

284 if self._ALL_INFO_FILLED_ON_STARTUP or self.participants_filled: 

285 return 

286 

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

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

289 orm.add(self.stored) 

290 with orm.no_autoflush: 

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

292 if self.participants_filled: 

293 return 

294 parts: list[Participant] = [] 

295 resources = set[str]() 

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

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

298 async for participant in self.fill_participants(): 

299 if participant.stored.resource in resources: 

300 self.log.debug( 

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

302 participant.stored.resource, 

303 ) 

304 continue 

305 parts.append(participant.stored) 

306 resources.add(participant.stored.resource) 

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

308 orm.add(self.stored) 

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

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

311 # and the participant_filled attribute. 

312 with orm.no_autoflush: 

313 orm.refresh(self.stored) 

314 for part in parts: 

315 orm.merge(part) 

316 self.stored.participants_filled = True 

317 orm.commit() 

318 

319 async def get_participants( 

320 self, affiliation: MucAffiliation | None = None 

321 ) -> AsyncIterator[LegacyParticipantType]: 

322 await self.__fill_participants() 

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

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

325 for db_participant in self.stored.participants: 

326 if ( 

327 affiliation is not None 

328 and db_participant.affiliation != affiliation 

329 ): 

330 continue 

331 yield self.participant_from_store(db_participant) 

332 

333 async def __fill_history(self) -> None: 

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

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

336 orm.add(self.stored) 

337 with orm.no_autoflush: 

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

339 if self.stored.history_filled: 

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

341 return 

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

343 try: 

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

345 if before is not None: 

346 before = before._replace( 

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

348 ) 

349 if after is not None: 

350 after = after._replace( 

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

352 ) 

353 await self.backfill(before, after) 

354 except NotImplementedError: 

355 return 

356 except Exception as e: 

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

358 

359 self.stored.history_filled = True 

360 self.commit(merge=True) 

361 

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

363 return self.name 

364 

365 @property 

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

367 return self.stored.name 

368 

369 @name.setter 

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

371 if self.name == n: 

372 return 

373 self.update_stored_attribute(name=n) 

374 self._set_logger() 

375 self.__send_configuration_change((104,)) 

376 

377 @property 

378 def description(self): 

379 return self.stored.description or "" 

380 

381 @description.setter 

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

383 if self.description == d: 

384 return 

385 self.update_stored_attribute(description=d) 

386 self.__send_configuration_change((104,)) 

387 

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

389 pto = p.get_to() 

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

391 return 

392 

393 pfrom = p.get_from() 

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

395 return 

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

397 if pto.resource != self.user_nick: 

398 self.log.debug( 

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

400 ) 

401 self.remove_user_resource(resource) 

402 else: 

403 self.log.debug( 

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

405 ) 

406 

407 async def update_info(self): 

408 """ 

409 Fetch information about this group from the legacy network 

410 

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

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

413 of participants etc. 

414 

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

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

417 is no change, you should not call 

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

419 attempt to modify 

420 the :attr:.avatar property. 

421 """ 

422 raise NotImplementedError 

423 

424 async def backfill( 

425 self, 

426 after: HoleBound | None = None, 

427 before: HoleBound | None = None, 

428 ): 

429 """ 

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

431 

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

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

434 run for a given group. 

435 

436 :param after: Fetch messages after this one. 

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

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

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

440 the user registered. 

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

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

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

444 :param before: Fetch messages before this one. 

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

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

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

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

449 """ 

450 raise NotImplementedError 

451 

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

453 """ 

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

455 

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

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

458 before yielding them. 

459 """ 

460 return 

461 yield 

462 

463 @property 

464 def subject(self) -> str: 

465 return self.stored.subject or "" 

466 

467 @subject.setter 

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

469 if s == self.subject: 

470 return 

471 

472 self.update_stored_attribute(subject=s) 

473 self.__get_subject_setter_participant().set_room_subject( 

474 s, None, self.subject_date, False 

475 ) 

476 

477 @property 

478 def is_anonymous(self): 

479 return self.type == MucType.CHANNEL 

480 

481 @property 

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

483 return self.stored.subject_setter 

484 

485 @subject_setter.setter 

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

487 if isinstance(subject_setter, LegacyContact): 

488 subject_setter = subject_setter.name 

489 elif isinstance(subject_setter, LegacyParticipant): 

490 subject_setter = subject_setter.nickname 

491 

492 if subject_setter == self.subject_setter: 

493 return 

494 assert isinstance(subject_setter, str | None) 

495 self.update_stored_attribute(subject_setter=subject_setter) 

496 

497 def __get_subject_setter_participant(self) -> LegacyParticipant: 

498 if self.subject_setter is None: 

499 return self.get_system_participant() 

500 return self._participant_cls( 

501 self, 

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

503 ) 

504 

505 def features(self): 

506 features = [ 

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

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

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

510 "urn:xmpp:mam:2", 

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

512 "urn:xmpp:sid:0", 

513 "muc_persistent", 

514 "vcard-temp", 

515 "urn:xmpp:ping", 

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

517 "jabber:iq:register", 

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

519 ] 

520 if self.type == MucType.GROUP: 

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

522 elif self.type == MucType.CHANNEL: 

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

524 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

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

526 return features 

527 

528 async def extended_features(self): 

529 is_group = self.type == MucType.GROUP 

530 

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

532 

533 form.add_field( 

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

535 ) 

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

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

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

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

540 

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

542 self._ALL_INFO_FILLED_ON_STARTUP or self.stored.participants_filled 

543 ): 

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

545 n = orm.scalar( 

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

547 room_id=self.stored.id 

548 ) 

549 ) 

550 else: 

551 n = self.n_participants 

552 if n is not None: 

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

554 

555 if d := self.description: 

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

557 

558 if s := self.subject: 

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

560 

561 if name := self.name: 

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

563 

564 if self._set_avatar_task is not None: 

565 await self._set_avatar_task 

566 avatar = self.get_avatar() 

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

568 form.add_field( 

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

570 ) 

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

572 

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

574 form.add_field( 

575 "muc#roomconfig_whois", 

576 "list-single", 

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

578 ) 

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

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

581 

582 r = [form] 

583 

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

585 r.append(reaction_form) 

586 

587 return r 

588 

589 def shutdown(self) -> None: 

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

591 for user_full_jid in self.user_full_jids(): 

592 presence = self.xmpp.make_presence( 

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

594 ) 

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

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

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

598 presence.send() 

599 

600 def user_full_jids(self): 

601 for r in self.get_user_resources(): 

602 j = JID(self.user_jid) 

603 j.resource = r 

604 yield j 

605 

606 @property 

607 def user_muc_jid(self): 

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

609 return user_muc_jid 

610 

611 async def echo( 

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

613 ) -> str: 

614 msg.set_from(self.user_muc_jid) 

615 if legacy_msg_id: 

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

617 else: 

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

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

620 

621 user_part = await self.get_user_participant() 

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

623 

624 self.archive.add(msg, user_part) 

625 

626 for user_full_jid in self.user_full_jids(): 

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

628 msg = copy(msg) 

629 msg.set_to(user_full_jid) 

630 

631 msg.send() 

632 

633 return msg["stanza_id"]["id"] 

634 

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

636 self.__send_configuration_change((104,)) 

637 self._send_room_presence() 

638 

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

640 if user_full_jid is None: 

641 tos = self.user_full_jids() 

642 else: 

643 tos = [user_full_jid] 

644 for to in tos: 

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

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

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

648 else: 

649 p["vcard_temp_update"]["photo"] = "" 

650 p.send() 

651 

652 @timeit 

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

654 user_full_jid = join_presence.get_from() 

655 requested_nickname = join_presence.get_to().resource 

656 client_resource = user_full_jid.resource 

657 

658 if client_resource in self.get_user_resources(): 

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

660 

661 if not requested_nickname or not client_resource: 

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

663 

664 self.add_user_resource(client_resource) 

665 

666 self.log.debug( 

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

668 client_resource, 

669 self.user_jid, 

670 self.legacy_id, 

671 requested_nickname, 

672 ) 

673 

674 user_nick = self.user_nick 

675 user_participant = None 

676 async for participant in self.get_participants(): 

677 if participant.is_user: 

678 user_participant = participant 

679 continue 

680 participant.send_initial_presence(full_jid=user_full_jid) 

681 

682 if user_participant is None: 

683 user_participant = await self.get_user_participant() 

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

685 orm.add(self.stored) 

686 with orm.no_autoflush: 

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

688 if not user_participant.is_user: 

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

690 user_participant.is_user = True 

691 user_participant.send_initial_presence( 

692 user_full_jid, 

693 presence_id=join_presence["id"], 

694 nick_change=user_nick != requested_nickname, 

695 ) 

696 

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

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

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

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

701 try: 

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

703 except ValueError: 

704 since = None 

705 if seconds is not None: 

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

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

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

709 else: 

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

711 await self.__fill_history() 

712 await self.__old_school_history( 

713 user_full_jid, 

714 maxchars=maxchars, 

715 maxstanzas=maxstanzas, 

716 since=since, 

717 ) 

718 if self.HAS_SUBJECT: 

719 subject = self.subject or "" 

720 else: 

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

722 self.__get_subject_setter_participant().set_room_subject( 

723 subject, 

724 user_full_jid, 

725 self.subject_date, 

726 ) 

727 if t := self._set_avatar_task: 

728 await t 

729 self._send_room_presence(user_full_jid) 

730 

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

732 """ 

733 Get the participant representing the gateway user 

734 

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

736 construction (optional) 

737 :return: 

738 """ 

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

740 self.__store_participant(p) 

741 return p 

742 

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

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

745 return 

746 try: 

747 p.commit(merge=True) 

748 except IntegrityError as e: 

749 if self._ALL_INFO_FILLED_ON_STARTUP: 

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

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

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

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

754 ) 

755 p.stored.room = self.stored 

756 orm.add(p.stored) 

757 orm.commit() 

758 else: 

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

760 

761 @overload 

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

763 

764 @overload 

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

766 

767 @overload 

768 async def get_participant( 

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

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

771 

772 @overload 

773 async def get_participant( 

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

775 ) -> "LegacyParticipantType": ... 

776 

777 @overload 

778 async def get_participant( 

779 self, nickname: str, *, occupant_id: str 

780 ) -> "LegacyParticipantType": ... 

781 

782 @overload 

783 async def get_participant( 

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

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

786 

787 @overload 

788 async def get_participant( 

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

790 ) -> "LegacyParticipantType": ... 

791 

792 @overload 

793 async def get_participant( 

794 self, 

795 nickname: str, 

796 *, 

797 create: Literal[True], 

798 is_user: bool, 

799 fill_first: bool, 

800 store: bool, 

801 ) -> "LegacyParticipantType": ... 

802 

803 @overload 

804 async def get_participant( 

805 self, 

806 nickname: str, 

807 *, 

808 create: Literal[False], 

809 is_user: bool, 

810 fill_first: bool, 

811 store: bool, 

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

813 

814 @overload 

815 async def get_participant( 

816 self, 

817 nickname: str, 

818 *, 

819 create: bool, 

820 fill_first: bool, 

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

822 

823 async def get_participant( 

824 self, 

825 nickname: str | None = None, 

826 *, 

827 create: bool = True, 

828 is_user: bool = False, 

829 fill_first: bool = False, 

830 store: bool = True, 

831 occupant_id: str | None = None, 

832 ) -> "LegacyParticipantType | None": 

833 """ 

834 Get a participant by their nickname. 

835 

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

837 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

838 

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

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

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

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

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

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

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

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

847 xep:`0421` 

848 :return: A participant of this room. 

849 """ 

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

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

852 if fill_first: 

853 await self.__fill_participants() 

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

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

856 if occupant_id is not None: 

857 stored = ( 

858 orm.query(Participant) 

859 .filter( 

860 Participant.room == self.stored, 

861 Participant.occupant_id == occupant_id, 

862 ) 

863 .one_or_none() 

864 ) 

865 elif nickname is not None: 

866 stored = ( 

867 orm.query(Participant) 

868 .filter( 

869 Participant.room == self.stored, 

870 (Participant.nickname == nickname) 

871 | (Participant.resource == nickname), 

872 ) 

873 .one_or_none() 

874 ) 

875 else: 

876 raise RuntimeError("NEVER") 

877 if stored is not None: 

878 if occupant_id and occupant_id != stored.occupant_id: 

879 warnings.warn( 

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

881 ) 

882 part = self.participant_from_store(stored) 

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

884 stored.nickname = nickname 

885 orm.add(stored) 

886 orm.commit() 

887 return part 

888 

889 if not create: 

890 return None 

891 

892 if occupant_id is None: 

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

894 

895 if nickname is None: 

896 nickname = occupant_id 

897 

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

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

900 if is_user: 

901 self.user_nick = nickname 

902 

903 p = self._participant_cls( 

904 self, 

905 Participant( 

906 room=self.stored, 

907 nickname=nickname or occupant_id, 

908 is_user=is_user, 

909 occupant_id=occupant_id, 

910 ), 

911 ) 

912 if store: 

913 self.__store_participant(p) 

914 if ( 

915 not self.get_lock("fill participants") 

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

917 and self.stored.participants_filled 

918 and not p.is_user 

919 and not p.is_system 

920 ): 

921 p.send_affiliation_change() 

922 return p 

923 

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

925 """ 

926 Get a pseudo-participant, representing the room itself 

927 

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

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

930 service 

931 :return: 

932 """ 

933 return self._participant_cls( 

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

935 ) 

936 

937 @overload 

938 async def get_participant_by_contact( 

939 self, c: "LegacyContact[Any]" 

940 ) -> "LegacyParticipantType": ... 

941 

942 @overload 

943 async def get_participant_by_contact( 

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

945 ) -> "LegacyParticipantType": ... 

946 

947 @overload 

948 async def get_participant_by_contact( 

949 self, 

950 c: "LegacyContact[Any]", 

951 *, 

952 create: Literal[False], 

953 occupant_id: str | None, 

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

955 

956 @overload 

957 async def get_participant_by_contact( 

958 self, 

959 c: "LegacyContact[Any]", 

960 *, 

961 create: Literal[True], 

962 occupant_id: str | None, 

963 ) -> "LegacyParticipantType": ... 

964 

965 async def get_participant_by_contact( 

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

967 ) -> "LegacyParticipantType | None": 

968 """ 

969 Get a non-anonymous participant. 

970 

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

972 that the Contact jid is associated to this participant 

973 

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

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

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

977 this participant. 

978 :return: 

979 """ 

980 await self.session.contacts.ready 

981 

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

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

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

985 stored = ( 

986 orm.query(Participant) 

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

988 .one_or_none() 

989 ) 

990 if stored is None: 

991 if not create: 

992 return None 

993 else: 

994 if occupant_id and stored.occupant_id != occupant_id: 

995 warnings.warn( 

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

997 ) 

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

999 

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

1001 

1002 if self.stored.id is None: 

1003 nick_available = True 

1004 else: 

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

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

1007 orm, self.stored.id, nickname 

1008 ) 

1009 

1010 if not nick_available: 

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

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

1013 p = self._participant_cls( 

1014 self, 

1015 Participant( 

1016 nickname=nickname, 

1017 room=self.stored, 

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

1019 ), 

1020 contact=c, 

1021 ) 

1022 

1023 self.__store_participant(p) 

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

1025 # during participants fill and history backfill we do not 

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

1027 # and role afterwards. 

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

1029 if ( 

1030 self.stored.participants_filled 

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

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

1033 ): 

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

1035 return p 

1036 

1037 @overload 

1038 async def get_participant_by_legacy_id( 

1039 self, legacy_id: LegacyUserIdType 

1040 ) -> "LegacyParticipantType": ... 

1041 

1042 @overload 

1043 async def get_participant_by_legacy_id( 

1044 self, 

1045 legacy_id: LegacyUserIdType, 

1046 *, 

1047 occupant_id: str | None, 

1048 create: Literal[True], 

1049 ) -> "LegacyParticipantType": ... 

1050 

1051 @overload 

1052 async def get_participant_by_legacy_id( 

1053 self, 

1054 legacy_id: LegacyUserIdType, 

1055 *, 

1056 occupant_id: str | None, 

1057 create: Literal[False], 

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

1059 

1060 async def get_participant_by_legacy_id( 

1061 self, 

1062 legacy_id: LegacyUserIdType, 

1063 *, 

1064 occupant_id: str | None = None, 

1065 create: bool = True, 

1066 ) -> "LegacyParticipantType": 

1067 try: 

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

1069 except ContactIsUser: 

1070 return await self.get_user_participant(occupant_id=occupant_id) 

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

1072 c, create=create, occupant_id=occupant_id 

1073 ) 

1074 

1075 def remove_participant( 

1076 self, 

1077 p: "LegacyParticipantType", 

1078 kick: bool = False, 

1079 ban: bool = False, 

1080 reason: str | None = None, 

1081 ): 

1082 """ 

1083 Call this when a participant leaves the room 

1084 

1085 :param p: The participant 

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

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

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

1089 """ 

1090 if kick and ban: 

1091 raise TypeError("Either kick or ban") 

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

1093 orm.delete(p.stored) 

1094 orm.commit() 

1095 if kick: 

1096 codes = {307} 

1097 elif ban: 

1098 codes = {301} 

1099 else: 

1100 codes = None 

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

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

1103 p.stored.role = "none" 

1104 if reason: 

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

1106 p._send(presence) 

1107 

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

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

1110 stored = ( 

1111 orm.query(Participant) 

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

1113 .one_or_none() 

1114 ) 

1115 if stored is None: 

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

1117 return 

1118 p = self.participant_from_store(stored) 

1119 if p.nickname == old_nickname: 

1120 p.nickname = new_nickname 

1121 

1122 async def __old_school_history( 

1123 self, 

1124 full_jid: JID, 

1125 maxchars: int | None = None, 

1126 maxstanzas: int | None = None, 

1127 seconds: int | None = None, 

1128 since: datetime | None = None, 

1129 ) -> None: 

1130 """ 

1131 Old-style history join (internal slidge use) 

1132 

1133 :param full_jid: 

1134 :param maxchars: 

1135 :param maxstanzas: 

1136 :param seconds: 

1137 :param since: 

1138 :return: 

1139 """ 

1140 if since is None: 

1141 if seconds is None: 

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

1143 else: 

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

1145 else: 

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

1147 

1148 for h_msg in self.archive.get_all( 

1149 start_date=start_date, end_date=None, last_page_n=maxstanzas 

1150 ): 

1151 msg = h_msg.stanza_component_ns 

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

1153 msg.set_to(full_jid) 

1154 self.xmpp.send(msg, False) 

1155 

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

1157 await self.__fill_history() 

1158 

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

1160 

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

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

1163 

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

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

1166 

1167 sender = form_values.get("with") 

1168 

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

1170 

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

1172 try: 

1173 max_results = int(max_str) 

1174 except ValueError: 

1175 max_results = None 

1176 else: 

1177 max_results = None 

1178 

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

1180 after_id = after_id_rsm or after_id 

1181 

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

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

1184 last_page_n = max_results 

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

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

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

1188 if before_rsm is not True: 

1189 before_id = before_rsm 

1190 else: 

1191 last_page_n = None 

1192 

1193 first = None 

1194 last = None 

1195 count = 0 

1196 

1197 it = self.archive.get_all( 

1198 start_date, 

1199 end_date, 

1200 before_id, 

1201 after_id, 

1202 ids, 

1203 last_page_n, 

1204 sender, 

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

1206 ) 

1207 

1208 for history_msg in it: 

1209 last = xmpp_id = history_msg.id 

1210 if first is None: 

1211 first = xmpp_id 

1212 

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

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

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

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

1217 

1218 wrapper_msg.send() 

1219 count += 1 

1220 

1221 if max_results and count == max_results: 

1222 break 

1223 

1224 if max_results: 

1225 try: 

1226 next(it) 

1227 except StopIteration: 

1228 complete = True 

1229 else: 

1230 complete = False 

1231 else: 

1232 complete = True 

1233 

1234 reply = iq.reply() 

1235 if not self.STABLE_ARCHIVE: 

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

1237 if complete: 

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

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

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

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

1242 reply.send() 

1243 

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

1245 await self.__fill_history() 

1246 await self.archive.send_metadata(iq) 

1247 

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

1249 """ 

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

1251 

1252 :param r: The resource to kick 

1253 """ 

1254 pto = JID(self.user_jid) 

1255 pto.resource = r 

1256 p = self.xmpp.make_presence( 

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

1258 ) 

1259 p["type"] = "unavailable" 

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

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

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

1263 p.send() 

1264 

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

1266 item = Item() 

1267 item["id"] = self.jid 

1268 

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

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

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

1272 

1273 try: 

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

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

1276 return None 

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

1278 # (slixmpp annoying magic) 

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

1280 item["id"] = self.jid 

1281 return item 

1282 except IqTimeout: 

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

1284 return None 

1285 except IqError as exc: 

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

1287 return None 

1288 except PermissionError: 

1289 warnings.warn( 

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

1291 ) 

1292 return None 

1293 

1294 async def add_to_bookmarks( 

1295 self, 

1296 auto_join: bool = True, 

1297 preserve: bool = True, 

1298 pin: bool | None = None, 

1299 notify: WhenLiteral | None = None, 

1300 ) -> None: 

1301 """ 

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

1303 

1304 This requires that slidge has the IQ privileged set correctly 

1305 on the XMPP server 

1306 

1307 :param auto_join: whether XMPP clients should automatically join 

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

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

1310 join if they are online. 

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

1312 set by the user outside slidge 

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

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

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

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

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

1318 """ 

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

1320 

1321 new = Item() 

1322 new["id"] = self.jid 

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

1324 

1325 if existing is None: 

1326 change = True 

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

1328 else: 

1329 change = False 

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

1331 

1332 existing_extensions = existing is not None and existing[ 

1333 "conference" 

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

1335 

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

1337 if existing_extensions: 

1338 assert existing is not None 

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

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

1341 if notify is not None: 

1342 continue 

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

1344 if pin is not None: 

1345 continue 

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

1347 

1348 if pin is not None: 

1349 if existing_extensions: 

1350 assert existing is not None 

1351 existing_pin = ( 

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

1353 "pinned", check=True 

1354 ) 

1355 is not None 

1356 ) 

1357 if existing_pin != pin: 

1358 change = True 

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

1360 

1361 if notify is not None: 

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

1363 if existing_extensions: 

1364 assert existing is not None 

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

1366 "notify", check=True 

1367 ) 

1368 if existing_notify is None: 

1369 change = True 

1370 else: 

1371 if existing_notify.get_config() != notify: 

1372 change = True 

1373 for el in existing_notify: 

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

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

1376 

1377 if change: 

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

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

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

1381 

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

1383 

1384 try: 

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

1386 except PermissionError: 

1387 warnings.warn( 

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

1389 ) 

1390 # fallback by forcing invitation 

1391 bookmark_add_fail = True 

1392 except IqError as e: 

1393 warnings.warn( 

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

1395 ) 

1396 # fallback by forcing invitation 

1397 bookmark_add_fail = True 

1398 else: 

1399 bookmark_add_fail = False 

1400 else: 

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

1402 return 

1403 

1404 if bookmark_add_fail: 

1405 self.session.send_gateway_invite( 

1406 self, 

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

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

1409 "Contact your administrator.", 

1410 ) 

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

1412 "always_invite_when_adding_bookmarks", True 

1413 ): 

1414 self.session.send_gateway_invite( 

1415 self, 

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

1417 ) 

1418 

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

1420 """ 

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

1422 client. 

1423 

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

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

1426 updated on the XMPP side. 

1427 

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

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

1430 room update event. 

1431 

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

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

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

1435 correct. 

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

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

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

1439 """ 

1440 raise NotImplementedError 

1441 

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

1443 

1444 async def on_set_affiliation( 

1445 self, 

1446 contact: "LegacyContact", 

1447 affiliation: MucAffiliation, 

1448 reason: str | None, 

1449 nickname: str | None, 

1450 ): 

1451 """ 

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

1453 for this group. 

1454 

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

1456 

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

1458 :param affiliation: The new affiliation 

1459 :param reason: A reason for this affiliation change 

1460 :param nickname: 

1461 """ 

1462 raise NotImplementedError 

1463 

1464 async def on_kick(self, contact: "LegacyContact", reason: str | None): 

1465 """ 

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

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

1468 

1469 :param contact: Contact to be kicked 

1470 :param reason: A reason for this kick 

1471 """ 

1472 raise NotImplementedError 

1473 

1474 async def on_set_config( 

1475 self, 

1476 name: str | None, 

1477 description: str | None, 

1478 ): 

1479 """ 

1480 Triggered when the user requests changing the room configuration. 

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

1482 

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

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

1485 

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

1487 be ``None``. 

1488 

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

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

1491 """ 

1492 raise NotImplementedError 

1493 

1494 async def on_destroy_request(self, reason: str | None): 

1495 """ 

1496 Triggered when the user requests room destruction. 

1497 

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

1499 """ 

1500 raise NotImplementedError 

1501 

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

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

1504 await self.__fill_participants() 

1505 orm.add(self.stored) 

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

1507 

1508 if len(participants) == 0: 

1509 return [] 

1510 

1511 result = [] 

1512 for match in re.finditer( 

1513 "|".join( 

1514 sorted( 

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

1516 key=lambda nick: len(nick), 

1517 reverse=True, 

1518 ) 

1519 ), 

1520 text, 

1521 ): 

1522 span = match.span() 

1523 nick = match.group() 

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

1525 continue 

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

1527 participant = self.participant_from_store( 

1528 stored=participants[nick], 

1529 ) 

1530 if contact := participant.contact: 

1531 result.append( 

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

1533 ) 

1534 return result 

1535 

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

1537 """ 

1538 Triggered when the user requests changing the room subject. 

1539 

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

1541 instance. 

1542 

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

1544 """ 

1545 raise NotImplementedError 

1546 

1547 async def on_set_thread_subject( 

1548 self, thread: LegacyThreadType, subject: str 

1549 ) -> None: 

1550 """ 

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

1552 

1553 :param thread: Legacy identifier of the thread 

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

1555 """ 

1556 raise NotImplementedError 

1557 

1558 @property 

1559 def participants_filled(self) -> bool: 

1560 return self.stored.participants_filled 

1561 

1562 def get_archived_messages( 

1563 self, msg_id: LegacyMessageType | str 

1564 ) -> Iterator[HistoryMessage]: 

1565 """ 

1566 Query the slidge archive for messages sent in this group 

1567 

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

1569 or an XMPP ID. 

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

1571 because of multi-attachment messages. 

1572 """ 

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

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

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

1576 ): 

1577 yield HistoryMessage(stored.stanza) 

1578 

1579 

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

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

1582 sub.attrib["id"] = origin_id 

1583 msg.xml.append(sub) 

1584 

1585 

1586def int_or_none(x): 

1587 try: 

1588 return int(x) 

1589 except ValueError: 

1590 return None 

1591 

1592 

1593def equals_zero(x): 

1594 if x is None: 

1595 return False 

1596 else: 

1597 return x == 0 

1598 

1599 

1600def str_to_datetime_or_none(date: str | None): 

1601 if date is None: 

1602 return 

1603 try: 

1604 return str_to_datetime(date) 

1605 except ValueError: 

1606 return None 

1607 

1608 

1609def bookmarks_form(): 

1610 form = Form() 

1611 form["type"] = "submit" 

1612 form.add_field( 

1613 "FORM_TYPE", 

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

1615 ftype="hidden", 

1616 ) 

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

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

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

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

1621 return form 

1622 

1623 

1624_BOOKMARKS_OPTIONS = bookmarks_form() 

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

1626 

1627log = logging.getLogger(__name__)