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

706 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-04 08:17 +0000

1import json 

2import logging 

3import re 

4import string 

5import warnings 

6from copy import copy 

7from datetime import datetime, timedelta, timezone 

8from typing import TYPE_CHECKING, AsyncIterator, Generic, Optional, Type, Union 

9from uuid import uuid4 

10 

11import sqlalchemy as sa 

12from slixmpp import JID, Iq, Message, Presence 

13from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

14from slixmpp.plugins.xep_0004 import Form 

15from slixmpp.plugins.xep_0060.stanza import Item 

16from slixmpp.plugins.xep_0082 import parse as str_to_datetime 

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

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

19from slixmpp.plugins.xep_0492.stanza import WhenLiteral 

20from slixmpp.xmlstream import ET 

21from sqlalchemy.orm import Session as OrmSession 

22 

23from ..contact.contact import LegacyContact 

24from ..contact.roster import ContactIsUser 

25from ..core.mixins.avatar import AvatarMixin 

26from ..core.mixins.disco import ChatterDiscoMixin 

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

28from ..db.models import Participant, Room 

29from ..util.jid_escaping import unescape_node 

30from ..util.lock import NamedLockMixin 

31from ..util.types import ( 

32 HoleBound, 

33 LegacyGroupIdType, 

34 LegacyMessageType, 

35 LegacyParticipantType, 

36 LegacyUserIdType, 

37 Mention, 

38 MucAffiliation, 

39 MucType, 

40) 

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

42from .archive import MessageArchive 

43from .participant import LegacyParticipant, escape_nickname 

44 

45if TYPE_CHECKING: 

46 from ..core.session import BaseSession 

47 

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

49 

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

51 

52 

53class LegacyMUC( 

54 Generic[ 

55 LegacyGroupIdType, LegacyMessageType, LegacyParticipantType, LegacyUserIdType 

56 ], 

57 AvatarMixin, 

58 NamedLockMixin, 

59 ChatterDiscoMixin, 

60 ReactionRecipientMixin, 

61 ThreadRecipientMixin, 

62 metaclass=SubclassableOnce, 

63): 

64 """ 

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

66 

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

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

69 """ 

70 

71 max_history_fetch = 100 

72 

73 is_group = True 

74 

75 DISCO_TYPE = "text" 

76 DISCO_CATEGORY = "conference" 

77 

78 STABLE_ARCHIVE = False 

79 """ 

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

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

82 across restarts. 

83 

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

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

86 

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

88 """ 

89 

90 KEEP_BACKFILLED_PARTICIPANTS = False 

91 """ 

92 Set this to ``True`` if the participant list is not full after calling 

93 ``fill_participants()``. This is a workaround for networks with huge 

94 participant lists which do not map really well the MUCs where all presences 

95 are sent on join. 

96 It allows to ensure that the participants that last spoke (within the 

97 ``fill_history()`` method are effectively participants, thus making possible 

98 for XMPP clients to fetch their avatars. 

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.mam) 

139 

140 def participant_from_store( 

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

142 ) -> LegacyParticipantType: 

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

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

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

146 

147 @property 

148 def jid(self) -> JID: 

149 return self.stored.jid 

150 

151 @jid.setter 

152 def jid(self, x: JID): 

153 # FIXME: without this, mypy yields 

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

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

156 raise RuntimeError 

157 

158 @property 

159 def legacy_id(self): 

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

161 

162 def orm(self) -> OrmSession: 

163 return self.xmpp.store.session() 

164 

165 @property 

166 def type(self) -> MucType: 

167 return self.stored.muc_type 

168 

169 @type.setter 

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

171 if self.type == type_: 

172 return 

173 self.stored.muc_type = type_ 

174 self.commit() 

175 

176 @property 

177 def n_participants(self): 

178 return self.stored.n_participants 

179 

180 @n_participants.setter 

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

182 if self.stored.n_participants == n_participants: 

183 return 

184 self.stored.n_participants = n_participants 

185 self.commit() 

186 

187 @property 

188 def user_jid(self): 

189 return self.session.user_jid 

190 

191 def _set_logger(self) -> None: 

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

193 

194 def __repr__(self) -> str: 

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

196 

197 @property 

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

199 if self.stored.subject_date is None: 

200 return None 

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

202 

203 @subject_date.setter 

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

205 if self.subject_date == when: 

206 return 

207 self.stored.subject_date = when 

208 self.commit() 

209 

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

211 part = self.get_system_participant() 

212 part.send_configuration_change(codes) 

213 

214 @property 

215 def user_nick(self): 

216 return ( 

217 self.stored.user_nick 

218 or self.session.bookmarks.user_nick 

219 or self.user_jid.node 

220 ) 

221 

222 @user_nick.setter 

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

224 if nick == self.user_nick: 

225 return 

226 self.stored.user_nick = nick 

227 self.commit() 

228 

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

230 stored_set = self.get_user_resources() 

231 if resource in stored_set: 

232 return 

233 stored_set.add(resource) 

234 self.stored.user_resources = ( 

235 json.dumps(list(stored_set)) if stored_set else None 

236 ) 

237 self.commit() 

238 

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

240 stored_str = self.stored.user_resources 

241 if stored_str is None: 

242 return set() 

243 return set(json.loads(stored_str)) 

244 

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

246 stored_set = self.get_user_resources() 

247 if resource not in stored_set: 

248 return 

249 stored_set.remove(resource) 

250 self.stored.user_resources = ( 

251 json.dumps(list(stored_set)) if stored_set else None 

252 ) 

253 self.commit() 

254 

255 async def __fill_participants(self) -> None: 

256 if self.participants_filled: 

257 return 

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

259 parts: list[Participant] = [] 

260 resources: set[str] = set() 

261 async for participant in self.fill_participants(): 

262 if participant.stored.id is not None: 

263 continue 

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

265 # return a participant with a conflicting nick/resource. There is 

266 # a better way to fix this than the logic below, but this better way 

267 # has not been found yet. 

268 if participant.jid.resource in resources: 

269 if participant.contact is None: 

270 self.log.warning( 

271 "Ditching participant %s", participant.nickname 

272 ) 

273 del participant 

274 continue 

275 else: 

276 nickname = ( 

277 f"{participant.nickname} ({participant.contact.jid.node})" 

278 ) 

279 participant = self._participant_cls( 

280 self, 

281 Participant(nickname=nickname, room=self.stored), 

282 contact=participant.contact, 

283 ) 

284 resources.add(participant.jid.resource) 

285 parts.append(participant.stored) 

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

287 # FIXME: something must be wrong with all these refreshes and merge, 

288 # but I did not manage to get rid of them without getting various 

289 # sqlalchemy exceptions raised everywhere 

290 orm.add(self.stored) 

291 orm.refresh(self.stored) 

292 known = {p.resource for p in self.stored.participants} 

293 self.stored.participants_filled = True 

294 for part in parts: 

295 if part.resource in known: 

296 continue 

297 part = orm.merge(part) 

298 orm.add(part) 

299 self.stored.participants.append(part) 

300 orm.commit() 

301 orm.refresh(self.stored) 

302 

303 async def get_participants( 

304 self, affiliation: Optional[MucAffiliation] = None 

305 ) -> AsyncIterator[LegacyParticipantType]: 

306 await self.__fill_participants() 

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

308 orm.add(self.stored) 

309 for db_participant in self.stored.participants: 

310 if ( 

311 affiliation is not None 

312 and db_participant.affiliation != affiliation 

313 ): 

314 continue 

315 yield self.participant_from_store(db_participant) 

316 

317 async def __fill_history(self) -> None: 

318 if self.stored.history_filled: 

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

320 return 

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

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

323 if not self.KEEP_BACKFILLED_PARTICIPANTS: 

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

325 orm.add(self.stored) 

326 participants = list(self.stored.participants) 

327 try: 

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

329 if before is not None: 

330 before = before._replace( 

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

332 ) 

333 if after is not None: 

334 after = after._replace( 

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

336 ) 

337 await self.backfill(before, after) 

338 except NotImplementedError: 

339 return 

340 except Exception as e: 

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

342 if not self.KEEP_BACKFILLED_PARTICIPANTS: 

343 self.stored.participants = participants 

344 self.stored.history_filled = True 

345 self.commit(merge=True) 

346 

347 @property 

348 def DISCO_NAME(self) -> str: # type:ignore 

349 return self.name or "unnamed-room" 

350 

351 @property 

352 def name(self) -> str: 

353 return self.stored.name or "unnamed-room" 

354 

355 @name.setter 

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

357 if self.name == n: 

358 return 

359 self.stored.name = n 

360 self.commit() 

361 self._set_logger() 

362 self.__send_configuration_change((104,)) 

363 

364 @property 

365 def description(self): 

366 return self.stored.description or "" 

367 

368 @description.setter 

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

370 if self.description == d: 

371 return 

372 self.stored.description = d 

373 self.commit() 

374 self.__send_configuration_change((104,)) 

375 

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

377 pto = p.get_to() 

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

379 return 

380 

381 pfrom = p.get_from() 

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

383 return 

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

385 if pto.resource != self.user_nick: 

386 self.log.debug( 

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

388 ) 

389 self.remove_user_resource(resource) 

390 else: 

391 self.log.debug( 

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

393 ) 

394 

395 async def update_info(self): 

396 """ 

397 Fetch information about this group from the legacy network 

398 

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

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

401 of participants etc. 

402 

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

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

405 is no change, you should not call 

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

407 attempt to modify 

408 the :attr:.avatar property. 

409 """ 

410 raise NotImplementedError 

411 

412 async def backfill( 

413 self, 

414 after: Optional[HoleBound] = None, 

415 before: Optional[HoleBound] = None, 

416 ): 

417 """ 

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

419 

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

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

422 run for a given group. 

423 

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

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

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

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

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

429 up to the most recent one 

430 """ 

431 raise NotImplementedError 

432 

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

434 """ 

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

436 

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

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

439 before yielding them. 

440 """ 

441 return 

442 yield 

443 

444 @property 

445 def subject(self): 

446 return self.stored.subject 

447 

448 @subject.setter 

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

450 if s == self.subject: 

451 return 

452 

453 self.stored.subject = s 

454 self.commit() 

455 self.__get_subject_setter_participant().set_room_subject( 

456 s, None, self.subject_date, False 

457 ) 

458 

459 @property 

460 def is_anonymous(self): 

461 return self.type == MucType.CHANNEL 

462 

463 @property 

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

465 return self.stored.subject_setter 

466 

467 @subject_setter.setter 

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

469 if isinstance(subject_setter, LegacyContact): 

470 subject_setter = subject_setter.name 

471 elif isinstance(subject_setter, LegacyParticipant): 

472 subject_setter = subject_setter.nickname 

473 

474 if subject_setter == self.subject_setter: 

475 return 

476 assert isinstance(subject_setter, str | None) 

477 self.stored.subject_setter = subject_setter 

478 self.commit() 

479 

480 def __get_subject_setter_participant(self) -> LegacyParticipant: 

481 if self.subject_setter is None: 

482 return self.get_system_participant() 

483 return self._participant_cls(self, Participant(nickname=self.subject_setter)) 

484 

485 def features(self): 

486 features = [ 

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

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

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

490 "urn:xmpp:mam:2", 

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

492 "urn:xmpp:sid:0", 

493 "muc_persistent", 

494 "vcard-temp", 

495 "urn:xmpp:ping", 

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

497 "jabber:iq:register", 

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

499 ] 

500 if self.type == MucType.GROUP: 

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

502 elif self.type == MucType.CHANNEL: 

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

504 elif self.type == MucType.CHANNEL_NON_ANONYMOUS: 

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

506 return features 

507 

508 async def extended_features(self): 

509 is_group = self.type == MucType.GROUP 

510 

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

512 

513 form.add_field( 

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

515 ) 

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

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

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

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

520 

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

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

523 n = orm.scalar( 

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

525 ) 

526 else: 

527 n = self.n_participants 

528 if n is not None: 

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

530 

531 if d := self.description: 

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

533 

534 if s := self.subject: 

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

536 

537 if self._set_avatar_task is not None: 

538 await self._set_avatar_task 

539 avatar = self.get_avatar() 

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

541 form.add_field( 

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

543 ) 

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

545 

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

547 form.add_field( 

548 "muc#roomconfig_whois", 

549 "list-single", 

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

551 ) 

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

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

554 

555 r = [form] 

556 

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

558 r.append(reaction_form) 

559 

560 return r 

561 

562 def shutdown(self) -> None: 

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

564 for user_full_jid in self.user_full_jids(): 

565 presence = self.xmpp.make_presence( 

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

567 ) 

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

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

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

571 presence.send() 

572 

573 def user_full_jids(self): 

574 for r in self.get_user_resources(): 

575 j = JID(self.user_jid) 

576 j.resource = r 

577 yield j 

578 

579 @property 

580 def user_muc_jid(self): 

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

582 return user_muc_jid 

583 

584 async def echo( 

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

586 ) -> None: 

587 origin_id = msg.get_origin_id() 

588 

589 msg.set_from(self.user_muc_jid) 

590 msg.set_id(msg.get_id()) 

591 if origin_id: 

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

593 # is present 

594 set_origin_id(msg, origin_id) 

595 if legacy_msg_id: 

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

597 else: 

598 msg["stanza_id"]["id"] = str(uuid4()) 

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

600 msg["occupant-id"]["id"] = "slidge-user" 

601 

602 self.archive.add(msg, await self.get_user_participant()) 

603 

604 for user_full_jid in self.user_full_jids(): 

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

606 msg = copy(msg) 

607 msg.set_to(user_full_jid) 

608 

609 msg.send() 

610 

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

612 self.__send_configuration_change((104,)) 

613 self._send_room_presence() 

614 

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

616 if user_full_jid is None: 

617 tos = self.user_full_jids() 

618 else: 

619 tos = [user_full_jid] 

620 for to in tos: 

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

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

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

624 else: 

625 p["vcard_temp_update"]["photo"] = "" 

626 p.send() 

627 

628 @timeit 

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

630 user_full_jid = join_presence.get_from() 

631 requested_nickname = join_presence.get_to().resource 

632 client_resource = user_full_jid.resource 

633 

634 if client_resource in self.get_user_resources(): 

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

636 

637 if not requested_nickname or not client_resource: 

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

639 

640 self.add_user_resource(client_resource) 

641 

642 self.log.debug( 

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

644 client_resource, 

645 self.user_jid, 

646 self.legacy_id, 

647 requested_nickname, 

648 ) 

649 

650 user_nick = self.user_nick 

651 user_participant = None 

652 async for participant in self.get_participants(): 

653 if participant.is_user: 

654 user_participant = participant 

655 continue 

656 participant.send_initial_presence(full_jid=user_full_jid) 

657 

658 if user_participant is None: 

659 user_participant = await self.get_user_participant() 

660 if not user_participant.is_user: 

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

662 user_participant.is_user = True # type:ignore 

663 user_participant.send_initial_presence( 

664 user_full_jid, 

665 presence_id=join_presence["id"], 

666 nick_change=user_nick != requested_nickname, 

667 ) 

668 

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

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

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

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

673 try: 

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

675 except ValueError: 

676 since = None 

677 if seconds is not None: 

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

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

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

681 else: 

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

683 await self.__fill_history() 

684 await self.__old_school_history( 

685 user_full_jid, 

686 maxchars=maxchars, 

687 maxstanzas=maxstanzas, 

688 since=since, 

689 ) 

690 self.__get_subject_setter_participant().set_room_subject( 

691 self.subject if self.HAS_SUBJECT else (self.description or self.name), 

692 user_full_jid, 

693 self.subject_date, 

694 ) 

695 if t := self._set_avatar_task: 

696 await t 

697 self._send_room_presence(user_full_jid) 

698 

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

700 """ 

701 Get the participant representing the gateway user 

702 

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

704 construction (optional) 

705 :return: 

706 """ 

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

708 self.__store_participant(p) 

709 return p 

710 

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

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

713 return 

714 p.commit(merge=True) 

715 

716 async def get_participant( 

717 self, 

718 nickname: str, 

719 raise_if_not_found: bool = False, 

720 fill_first: bool = False, 

721 store: bool = True, 

722 is_user: bool = False, 

723 ) -> "LegacyParticipantType": 

724 """ 

725 Get a participant by their nickname. 

726 

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

728 :meth:`.LegacyMUC.get_participant_by_contact` instead. 

729 

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

731 :param raise_if_not_found: Raise XMPPError("item-not-found") if they are not 

732 in the participant list (internal use by slidge, plugins should not 

733 need that) 

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

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

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

737 :return: 

738 """ 

739 if fill_first: 

740 await self.__fill_participants() 

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

742 stored = ( 

743 orm.query(Participant) 

744 .filter( 

745 Participant.room == self.stored, 

746 (Participant.nickname == nickname) 

747 | (Participant.resource == nickname), 

748 ) 

749 .one_or_none() 

750 ) 

751 if stored is not None: 

752 return self.participant_from_store(stored) 

753 

754 if raise_if_not_found: 

755 raise XMPPError("item-not-found") 

756 p = self._participant_cls( 

757 self, Participant(room=self.stored, nickname=nickname, is_user=is_user) 

758 ) 

759 if store: 

760 self.__store_participant(p) 

761 if ( 

762 not self.get_lock("fill participants") 

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

764 and self.stored.participants_filled 

765 and not p.is_user 

766 and not p.is_system 

767 ): 

768 p.send_affiliation_change() 

769 return p 

770 

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

772 """ 

773 Get a pseudo-participant, representing the room itself 

774 

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

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

777 service 

778 :return: 

779 """ 

780 return self._participant_cls(self, Participant(), is_system=True) 

781 

782 async def get_participant_by_contact( 

783 self, c: "LegacyContact" 

784 ) -> "LegacyParticipantType": 

785 """ 

786 Get a non-anonymous participant. 

787 

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

789 that the Contact jid is associated to this participant 

790 

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

792 :return: 

793 """ 

794 await self.session.contacts.ready 

795 

796 if not self.get_lock("fill participants"): 

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

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

799 stored = ( 

800 orm.query(Participant) 

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

802 .one_or_none() 

803 ) 

804 if stored is not None: 

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

806 

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

808 

809 if self.stored.id is None: 

810 nick_available = True 

811 else: 

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

813 nick_available = ( 

814 orm.query(Participant.id).filter_by( 

815 room=self.stored, nickname=nickname 

816 ) 

817 ).one_or_none() is None 

818 

819 if not nick_available: 

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

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

822 p = self._participant_cls( 

823 self, Participant(nickname=nickname, room=self.stored), contact=c 

824 ) 

825 

826 self.__store_participant(p) 

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

828 # during participants fill and history backfill we do not 

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

830 # and role afterwards. 

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

832 if ( 

833 self.stored.participants_filled 

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

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

836 ): 

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

838 return p 

839 

840 async def get_participant_by_legacy_id( 

841 self, legacy_id: LegacyUserIdType 

842 ) -> "LegacyParticipantType": 

843 try: 

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

845 except ContactIsUser: 

846 return await self.get_user_participant() 

847 return await self.get_participant_by_contact(c) 

848 

849 def remove_participant( 

850 self, 

851 p: "LegacyParticipantType", 

852 kick: bool = False, 

853 ban: bool = False, 

854 reason: str | None = None, 

855 ): 

856 """ 

857 Call this when a participant leaves the room 

858 

859 :param p: The participant 

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

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

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

863 """ 

864 if kick and ban: 

865 raise TypeError("Either kick or ban") 

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

867 orm.delete(p.stored) 

868 orm.commit() 

869 if kick: 

870 codes = {307} 

871 elif ban: 

872 codes = {301} 

873 else: 

874 codes = None 

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

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

877 p.stored.role = "none" 

878 if reason: 

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

880 p._send(presence) 

881 

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

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

884 stored = ( 

885 orm.query(Participant) 

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

887 .one_or_none() 

888 ) 

889 if stored is None: 

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

891 return 

892 p = self.participant_from_store(stored) 

893 if p.nickname == old_nickname: 

894 p.nickname = new_nickname 

895 

896 async def __old_school_history( 

897 self, 

898 full_jid: JID, 

899 maxchars: Optional[int] = None, 

900 maxstanzas: Optional[int] = None, 

901 seconds: Optional[int] = None, 

902 since: Optional[datetime] = None, 

903 ) -> None: 

904 """ 

905 Old-style history join (internal slidge use) 

906 

907 :param full_jid: 

908 :param maxchars: 

909 :param maxstanzas: 

910 :param seconds: 

911 :param since: 

912 :return: 

913 """ 

914 if since is None: 

915 if seconds is None: 

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

917 else: 

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

919 else: 

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

921 

922 for h_msg in self.archive.get_all( 

923 start_date=start_date, end_date=None, last_page_n=maxstanzas 

924 ): 

925 msg = h_msg.stanza_component_ns 

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

927 msg.set_to(full_jid) 

928 self.xmpp.send(msg, False) 

929 

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

931 await self.__fill_history() 

932 

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

934 

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

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

937 

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

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

940 

941 sender = form_values.get("with") 

942 

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

944 

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

946 try: 

947 max_results = int(max_str) 

948 except ValueError: 

949 max_results = None 

950 else: 

951 max_results = None 

952 

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

954 after_id = after_id_rsm or after_id 

955 

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

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

958 last_page_n = max_results 

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

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

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

962 if before_rsm is not True: 

963 before_id = before_rsm 

964 else: 

965 last_page_n = None 

966 

967 first = None 

968 last = None 

969 count = 0 

970 

971 it = self.archive.get_all( 

972 start_date, 

973 end_date, 

974 before_id, 

975 after_id, 

976 ids, 

977 last_page_n, 

978 sender, 

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

980 ) 

981 

982 for history_msg in it: 

983 last = xmpp_id = history_msg.id 

984 if first is None: 

985 first = xmpp_id 

986 

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

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

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

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

991 

992 wrapper_msg.send() 

993 count += 1 

994 

995 if max_results and count == max_results: 

996 break 

997 

998 if max_results: 

999 try: 

1000 next(it) 

1001 except StopIteration: 

1002 complete = True 

1003 else: 

1004 complete = False 

1005 else: 

1006 complete = True 

1007 

1008 reply = iq.reply() 

1009 if not self.STABLE_ARCHIVE: 

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

1011 if complete: 

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

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

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

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

1016 reply.send() 

1017 

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

1019 await self.__fill_history() 

1020 await self.archive.send_metadata(iq) 

1021 

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

1023 """ 

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

1025 

1026 :param r: The resource to kick 

1027 """ 

1028 pto = JID(self.user_jid) 

1029 pto.resource = r 

1030 p = self.xmpp.make_presence( 

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

1032 ) 

1033 p["type"] = "unavailable" 

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

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

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

1037 p.send() 

1038 

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

1040 item = Item() 

1041 item["id"] = self.jid 

1042 

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

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

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

1046 

1047 try: 

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

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

1050 return None 

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

1052 # (slixmpp annoying magic) 

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

1054 item["id"] = self.jid 

1055 return item 

1056 except IqTimeout as exc: 

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

1058 return None 

1059 except IqError as exc: 

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

1061 return None 

1062 except PermissionError: 

1063 warnings.warn( 

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

1065 ) 

1066 return None 

1067 

1068 async def add_to_bookmarks( 

1069 self, 

1070 auto_join: bool = True, 

1071 preserve: bool = True, 

1072 pin: bool | None = None, 

1073 notify: WhenLiteral | None = None, 

1074 ) -> None: 

1075 """ 

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

1077 

1078 This requires that slidge has the IQ privileged set correctly 

1079 on the XMPP server 

1080 

1081 :param auto_join: whether XMPP clients should automatically join 

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

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

1084 join if they are online. 

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

1086 set by the user outside slidge 

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

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

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

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

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

1092 """ 

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

1094 

1095 new = Item() 

1096 new["id"] = self.jid 

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

1098 

1099 if existing is None: 

1100 change = True 

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

1102 else: 

1103 change = False 

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

1105 

1106 existing_extensions = existing is not None and existing[ 

1107 "conference" 

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

1109 

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

1111 if existing_extensions: 

1112 assert existing is not None 

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

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

1115 if notify is not None: 

1116 continue 

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

1118 if pin is not None: 

1119 continue 

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

1121 

1122 if pin is not None: 

1123 if existing_extensions: 

1124 assert existing is not None 

1125 existing_pin = ( 

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

1127 "pinned", check=True 

1128 ) 

1129 is not None 

1130 ) 

1131 if existing_pin != pin: 

1132 change = True 

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

1134 

1135 if notify is not None: 

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

1137 if existing_extensions: 

1138 assert existing is not None 

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

1140 "notify", check=True 

1141 ) 

1142 if existing_notify is None: 

1143 change = True 

1144 else: 

1145 if existing_notify.get_config() != notify: 

1146 change = True 

1147 for el in existing_notify: 

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

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

1150 

1151 if change: 

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

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

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

1155 

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

1157 

1158 try: 

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

1160 except PermissionError: 

1161 warnings.warn( 

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

1163 ) 

1164 # fallback by forcing invitation 

1165 bookmark_add_fail = True 

1166 except IqError as e: 

1167 warnings.warn( 

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

1169 ) 

1170 # fallback by forcing invitation 

1171 bookmark_add_fail = True 

1172 else: 

1173 bookmark_add_fail = False 

1174 else: 

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

1176 return 

1177 

1178 if bookmark_add_fail: 

1179 self.session.send_gateway_invite( 

1180 self, 

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

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

1183 "Contact your administrator.", 

1184 ) 

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

1186 "always_invite_when_adding_bookmarks", True 

1187 ): 

1188 self.session.send_gateway_invite( 

1189 self, 

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

1191 ) 

1192 

1193 async def on_avatar( 

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

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

1196 """ 

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

1198 client. 

1199 

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

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

1202 updated on the XMPP side. 

1203 

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

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

1206 room update event. 

1207 

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

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

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

1211 correct. 

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

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

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

1215 """ 

1216 raise NotImplementedError 

1217 

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

1219 

1220 async def on_set_affiliation( 

1221 self, 

1222 contact: "LegacyContact", 

1223 affiliation: MucAffiliation, 

1224 reason: Optional[str], 

1225 nickname: Optional[str], 

1226 ): 

1227 """ 

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

1229 for this group. 

1230 

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

1232 

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

1234 :param affiliation: The new affiliation 

1235 :param reason: A reason for this affiliation change 

1236 :param nickname: 

1237 """ 

1238 raise NotImplementedError 

1239 

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

1241 """ 

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

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

1244 

1245 :param contact: Contact to be kicked 

1246 :param reason: A reason for this kick 

1247 """ 

1248 raise NotImplementedError 

1249 

1250 async def on_set_config( 

1251 self, 

1252 name: Optional[str], 

1253 description: Optional[str], 

1254 ): 

1255 """ 

1256 Triggered when the user requests changing the room configuration. 

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

1258 

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

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

1261 

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

1263 be ``None``. 

1264 

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

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

1267 """ 

1268 raise NotImplementedError 

1269 

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

1271 """ 

1272 Triggered when the user requests room destruction. 

1273 

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

1275 """ 

1276 raise NotImplementedError 

1277 

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

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

1280 await self.__fill_participants() 

1281 orm.add(self.stored) 

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

1283 

1284 if len(participants) == 0: 

1285 return [] 

1286 

1287 result = [] 

1288 for match in re.finditer( 

1289 "|".join( 

1290 sorted( 

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

1292 key=lambda nick: len(nick), 

1293 reverse=True, 

1294 ) 

1295 ), 

1296 text, 

1297 ): 

1298 span = match.span() 

1299 nick = match.group() 

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

1301 continue 

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

1303 participant = self.participant_from_store( 

1304 stored=participants[nick], 

1305 ) 

1306 if contact := participant.contact: 

1307 result.append( 

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

1309 ) 

1310 return result 

1311 

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

1313 """ 

1314 Triggered when the user requests changing the room subject. 

1315 

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

1317 instance. 

1318 

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

1320 """ 

1321 raise NotImplementedError 

1322 

1323 @property 

1324 def participants_filled(self) -> bool: 

1325 return self.stored.participants_filled 

1326 

1327 

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

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

1330 sub.attrib["id"] = origin_id 

1331 msg.xml.append(sub) 

1332 

1333 

1334def int_or_none(x): 

1335 try: 

1336 return int(x) 

1337 except ValueError: 

1338 return None 

1339 

1340 

1341def equals_zero(x): 

1342 if x is None: 

1343 return False 

1344 else: 

1345 return x == 0 

1346 

1347 

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

1349 if date is None: 

1350 return 

1351 try: 

1352 return str_to_datetime(date) 

1353 except ValueError: 

1354 return None 

1355 

1356 

1357def bookmarks_form(): 

1358 form = Form() 

1359 form["type"] = "submit" 

1360 form.add_field( 

1361 "FORM_TYPE", 

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

1363 ftype="hidden", 

1364 ) 

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

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

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

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

1369 return form 

1370 

1371 

1372_BOOKMARKS_OPTIONS = bookmarks_form() 

1373_WHITESPACE_OR_PUNCTUATION = string.whitespace + string.punctuation 

1374 

1375log = logging.getLogger(__name__)