Coverage for slidge/room/room.py: 91%

668 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-10 12:26 +0000

1from __future__ import annotations 

2 

3import hashlib 

4import json 

5import logging 

6import re 

7import string 

8import uuid 

9from asyncio import Lock 

10from collections.abc import AsyncIterator, Iterable, Iterator 

11from contextlib import asynccontextmanager 

12from copy import copy 

13from datetime import UTC, datetime, timedelta 

14from typing import ( 

15 TYPE_CHECKING, 

16 Any, 

17 ClassVar, 

18 Literal, 

19 Self, 

20) 

21 

22import sqlalchemy as sa 

23from slixmpp import JID, Iq, Message, Presence 

24from slixmpp.exceptions import XMPPError 

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

26from slixmpp.plugins.xep_0045.stanza import MUCUserItem 

27from slixmpp.plugins.xep_0082 import parse as str_to_datetime 

28from slixmpp.plugins.xep_0492.stanza import WhenLiteral 

29from slixmpp.xmlstream import ET 

30from sqlalchemy.orm import Session as OrmSession 

31from typing_extensions import TypedDict 

32 

33from ..contact.contact import LegacyContact 

34from ..core.mixins.avatar import ImageMixin 

35from ..core.mixins.disco import ChatterDiscoMixin 

36from ..core.mixins.recipient import RecipientMixin 

37from ..db.models import ArchivedMessage, Participant, Room, Space 

38from ..space import LegacySpace 

39from ..util.types import ( 

40 AnyParticipant, 

41 ExtraAttrMapping, 

42 HoleBound, 

43 Mention, 

44 MucAffiliation, 

45 RoomMessage, 

46 RoomSticker, 

47 RoomType, 

48) 

49from ..util.util import derive_wired_class, timeit, uniquify_replace 

50from .archive import MessageArchive 

51from .participant import LegacyParticipant 

52from .participants import RoomRoster, get_resource 

53 

54if TYPE_CHECKING: 

55 from ..command.base import RoomCommand 

56 from ..core.session import BaseSession 

57 from ..db.avatar import CachedAvatar 

58 from .bookmarks import LegacyBookmarks 

59 

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

61 

62 

63type SubjectSetterType = "str | LegacyContact | AnyParticipant | None" 

64 

65 

66class _RoomProps(TypedDict, total=False): 

67 name: str | None 

68 n_participants: int | None 

69 subject_date: datetime | None 

70 space_legacy_id: str | None 

71 type: RoomType 

72 pinned: bool | None 

73 notify_settings: WhenLiteral | None 

74 subject_setter: SubjectSetterType | None 

75 user_nick: str | None 

76 

77 

78class LegacyRoom[ 

79 LegacyParticipantT: AnyParticipant = Any, 

80 T: ExtraAttrMapping = Any, 

81]( 

82 ImageMixin[T, _RoomProps], 

83 ChatterDiscoMixin, 

84 RecipientMixin, 

85): 

86 """ 

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

88 

89 Room instances are obtained by calling :py:meth:`slidge.group.rooms.LegacyBookmarks` 

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

91 """ 

92 

93 max_history_fetch = 100 

94 

95 is_group: Literal[True] = True 

96 

97 DISCO_TYPE = "text" 

98 DISCO_CATEGORY = "conference" 

99 

100 STABLE_ARCHIVE = False 

101 """ 

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

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

104 across restarts. 

105 

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

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

108 

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

110 """ 

111 

112 """ 

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

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

115 """ 

116 

117 HAS_DESCRIPTION = True 

118 """ 

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

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

121 room configuration form. 

122 """ 

123 

124 HAS_SUBJECT = True 

125 """ 

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

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

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

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

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

131 tries to set the room subject. 

132 """ 

133 

134 archive: MessageArchive 

135 

136 _stored: Room 

137 

138 _commands: ClassVar[dict[str, type[RoomCommand[Any]]]] = {} 

139 _commands_chat: ClassVar[dict[str, type[RoomCommand[Any]]]] = {} 

140 

141 _participant_cls: type[LegacyParticipantT] 

142 """ 

143 The concrete :class:`.LegacyParticipant` subclass this Room produces. 

144 

145 Derived automatically from the generic parameter, e.g., 

146 ``class Room(LegacyRoom[Participant])`` produces ``Participant`` instances. 

147 """ 

148 

149 is_participant: Literal[False] = False 

150 

151 extra_attributes: T 

152 

153 _session: BaseSession[Any, LegacyBookmarks[Self]] 

154 

155 def __init_subclass__(cls, **kwargs: object) -> None: 

156 super().__init_subclass__(**kwargs) 

157 derive_wired_class(cls, LegacyRoom, "_participant_cls") 

158 

159 def __init__( 

160 self, session: BaseSession[Any, LegacyBookmarks[Self]], stored: Room 

161 ) -> None: 

162 self._session = session 

163 self.xmpp = session.xmpp 

164 self._stored = stored 

165 self._set_logger() 

166 self.participants = RoomRoster[LegacyParticipantT](self) 

167 super().__init__() 

168 

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

170 

171 async def on_message(self, message: RoomMessage[LegacyParticipantT]) -> str | None: 

172 """ 

173 Triggered when the user sends a message to this :term:`Room`. 

174 

175 :return: A message ID of that can be used later to further reference 

176 this message (reactions, read marks, etc.). 

177 """ 

178 raise NotImplementedError 

179 

180 async def on_sticker(self, sticker: RoomSticker) -> str | None: 

181 """ 

182 Triggered when the user sends a sticker to this :term:`Room`. 

183 

184 :param sticker: The sticker sent by the user. 

185 

186 :return: A message ID of that can be used later to further reference 

187 this message (reactions, read marks, etc.). 

188 """ 

189 raise NotImplementedError 

190 

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

192 """ 

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

194 

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

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

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

198 

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

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

201 Room will be marked as read. 

202 

203 :param horizon_xmpp_id: The latest message 

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

205 """ 

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

207 assert self._stored.id is not None 

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

209 orm, self._stored.id, horizon_xmpp_id 

210 ) 

211 orm.commit() 

212 return ids 

213 

214 @property 

215 def jid(self) -> JID: 

216 return self._stored.jid 

217 

218 @property 

219 def legacy_id(self) -> str: 

220 return self._stored.legacy_id 

221 

222 def __stored_space(self) -> Space | None: 

223 self._stored.ensure_relation("space", self.xmpp.store.session) 

224 return self._stored.space 

225 

226 @property 

227 def space(self) -> LegacySpace | None: 

228 stored = self.__stored_space() 

229 return None if stored is None else self._session.spaces._from_store(stored) 

230 

231 @space.setter 

232 def space(self, space: LegacySpace | None) -> None: 

233 self._stored.ensure_relation("space", self.xmpp.store.session) 

234 old = self._stored.space 

235 

236 new = space._stored if space else None 

237 new_id = space._stored.id if space else None 

238 

239 self._update_stored_attribute(space_id=new_id) 

240 self._stored._set_committed_value("space", new) 

241 self._stored._set_committed_value("space_id", new_id) 

242 

243 if not self._session.rooms.ready.done(): 

244 return 

245 

246 if old is not None: 

247 old.ensure_relation("avatar", self._session.xmpp.store.session) 

248 old.ensure_relation("banner", self._session.xmpp.store.session) 

249 old.load_rooms(self._session.xmpp.store.session) 

250 self.xmpp.pubsub.broadcast_space( 

251 self._session, 

252 old, 

253 self._session.spaces.legacy_id_to_node(old.legacy_id), 

254 [str(self.jid)], 

255 ) 

256 if new is not None: 

257 new.ensure_relation("avatar", self._session.xmpp.store.session) 

258 new.ensure_relation("banner", self._session.xmpp.store.session) 

259 new.load_rooms(self._session.xmpp.store.session) 

260 self.xmpp.pubsub.broadcast_space( 

261 self._session, 

262 new, 

263 self._session.spaces.legacy_id_to_node(new.legacy_id), 

264 [str(self.jid)], 

265 ) 

266 

267 def __orm( 

268 self, 

269 **kwargs: Any, # noqa:ANN401 

270 ) -> OrmSession: 

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

272 

273 @property 

274 def type(self) -> RoomType: 

275 return self._stored.type 

276 

277 @type.setter 

278 def type(self, type_: RoomType) -> None: 

279 self._update_stored_attribute(type=type_) 

280 self._session.rooms.schedule_pep_add(self, "type change") 

281 

282 @property 

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

284 self._stored.ensure_loaded("disco", self.xmpp.store.session) 

285 return self._stored.n_participants 

286 

287 @n_participants.setter 

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

289 self._update_stored_attribute(n_participants=n_participants) 

290 

291 def _set_logger(self) -> None: 

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

293 

294 def __repr__(self) -> str: 

295 return f"<Room #{self._stored.id} '{self.name}' ({self._stored.legacy_id} - {self.jid.user})'>" 

296 

297 @property 

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

299 self._stored.ensure_loaded("subject", self.xmpp.store.session) 

300 if self._stored.subject_date is None: 

301 return None 

302 return self._stored.subject_date.replace(tzinfo=UTC) 

303 

304 @subject_date.setter 

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

306 self._update_stored_attribute(subject_date=when) 

307 

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

309 part = self.get_system_participant() 

310 part.send_configuration_change(codes) 

311 

312 @property 

313 def user_nick(self) -> str: 

314 return ( 

315 self._stored.user_nick 

316 or self._session.rooms.user_nick 

317 or self.user_jid.node 

318 ) 

319 

320 @user_nick.setter 

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

322 self._update_stored_attribute(user_nick=nick) 

323 

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

325 stored_set = self.get_user_resources() 

326 if resource in stored_set: 

327 return 

328 stored_set.add(resource) 

329 self._update_stored_attribute( 

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

331 ) 

332 

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

334 stored_str = self._stored.user_resources 

335 if stored_str is None: 

336 return set() 

337 return set(json.loads(stored_str)) 

338 

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

340 stored_set = self.get_user_resources() 

341 if resource not in stored_set: 

342 return 

343 stored_set.remove(resource) 

344 self._update_stored_attribute( 

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

346 ) 

347 

348 @asynccontextmanager 

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

350 async with self._session.lock((self.legacy_id, id_)): 

351 yield 

352 

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

354 return self._session.get_lock((self.legacy_id, id_)) 

355 

356 async def get_participants( 

357 self, affiliation: MucAffiliation | None = None 

358 ) -> AsyncIterator[LegacyParticipantT]: 

359 await self.participants.ensure_filled() 

360 for p in self.participants.__iter__(affiliation): 

361 yield p 

362 

363 async def __fill_history(self) -> None: 

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

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

366 self._stored._set_committed_value( 

367 "history_filled", 

368 orm.scalar( 

369 sa.select( 

370 Room.history_filled, 

371 ).where(Room.id == self._stored.id) 

372 ), 

373 ) 

374 if self._stored.history_filled: 

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

376 return 

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

378 try: 

379 await self.backfill(*self.archive.get_hole_bounds()) 

380 except NotImplementedError: 

381 return 

382 except Exception as e: 

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

384 finally: 

385 self._update_stored_attribute(history_filled=True) 

386 

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

388 return self.name 

389 

390 @property 

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

392 return self._stored.name 

393 

394 @name.setter 

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

396 if not self._update_stored_attribute(name=n): 

397 return 

398 self._set_logger() 

399 self.__send_configuration_change((104,)) 

400 

401 @property 

402 def description(self) -> str: 

403 self._stored.ensure_loaded("disco", self.xmpp.store.session) 

404 return self._stored.description or "" 

405 

406 @description.setter 

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

408 d = d or "" 

409 

410 if not self._update_stored_attribute(description=d): 

411 return 

412 self.__send_configuration_change((104,)) 

413 

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

415 pto = p.get_to() 

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

417 return 

418 

419 pfrom = p.get_from() 

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

421 return 

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

423 if pto.resource != self.user_nick: 

424 self.log.debug( 

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

426 ) 

427 self.remove_user_resource(resource) 

428 else: 

429 self.log.debug( 

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

431 ) 

432 

433 async def backfill( 

434 self, 

435 after: HoleBound | None = None, 

436 before: HoleBound | None = None, 

437 ) -> None: 

438 """ 

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

440 

441 In it, send history messages using ``self.participants.get_or_create(...).send_xxxx``. 

442 This is only called once per slidge run for a given group. 

443 

444 :param after: Fetch messages after this one. 

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

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

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

448 the user registered. 

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

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

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

452 :param before: Fetch messages before this one. 

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

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

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

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

457 """ 

458 raise NotImplementedError 

459 

460 async def fill_participants(self) -> AsyncIterator[LegacyParticipantT]: 

461 """ 

462 This method should yield the list of all members of this room. 

463 

464 It will only be called once per-room, only when actually needed by 

465 an XMPP action (typically joining the room). 

466 """ 

467 return 

468 yield 

469 

470 @property 

471 def subject(self) -> str: 

472 self._stored.ensure_loaded("subject", self.xmpp.store.session) 

473 return self._stored.subject or "" 

474 

475 @subject.setter 

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

477 if not self._update_stored_attribute(subject=s): 

478 return 

479 self.__get_subject_setter_participant().set_room_subject( 

480 s, None, self.subject_date, False 

481 ) 

482 

483 @property 

484 def is_anonymous(self) -> bool: 

485 return self.type == RoomType.CHANNEL 

486 

487 @property 

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

489 self._stored.ensure_loaded("subject", self.xmpp.store.session) 

490 return self._stored.subject_setter 

491 

492 @subject_setter.setter 

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

494 if isinstance(subject_setter, LegacyContact): 

495 subject_setter = subject_setter.name 

496 elif isinstance(subject_setter, LegacyParticipant): 

497 subject_setter = subject_setter.nickname 

498 

499 assert isinstance(subject_setter, str | None) 

500 self._update_stored_attribute(subject_setter=subject_setter) 

501 

502 def __get_subject_setter_participant(self) -> AnyParticipant: 

503 if self.subject_setter: 

504 nick = self.subject_setter or "subject-setter" 

505 return self._participant_cls( 

506 self, 

507 Participant( 

508 nickname=nick, 

509 resource=get_resource(nick), 

510 ), 

511 ) 

512 else: 

513 return self.get_system_participant() 

514 

515 @property 

516 def notify_settings(self) -> WhenLiteral | None: 

517 self._stored.ensure_loaded("bookmarks", self.__orm) 

518 return self._stored.notify_settings 

519 

520 @notify_settings.setter 

521 def notify_settings(self, when: WhenLiteral | None) -> None: 

522 if self._update_stored_attribute(notify_settings=when): 

523 self._session.rooms.schedule_pep_add(self, "notify setting change") 

524 

525 @property 

526 def pinned(self) -> bool | None: 

527 self._stored.ensure_loaded("bookmarks", self.__orm) 

528 return self._stored.pinned 

529 

530 @pinned.setter 

531 def pinned(self, pinned: bool | None) -> None: 

532 if self._update_stored_attribute(pinned=pinned): 

533 self._session.rooms.schedule_pep_add(self, "pinned change") 

534 

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

536 features = [ 

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

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

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

540 "urn:xmpp:mam:2", 

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

542 "urn:xmpp:sid:0", 

543 "muc_persistent", 

544 "vcard-temp", 

545 "urn:xmpp:ping", 

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

547 "jabber:iq:register", 

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

549 "urn:xmpp:muc:affiliations:1", 

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

551 ] 

552 if self.type == RoomType.GROUP: 

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

554 elif self.type == RoomType.CHANNEL: 

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

556 elif self.type == RoomType.CHANNEL_NON_ANONYMOUS: 

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

558 

559 if self._stored.space_id is not None: 

560 features.append("urn:xmpp:spaces:0") 

561 return features 

562 

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

564 is_group = self.type == RoomType.GROUP 

565 

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

567 

568 form.add_field( 

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

570 ) 

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

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

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

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

575 

576 self._stored.ensure_loaded("disco", self.xmpp.store.session) 

577 if self.participants_filled: 

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

579 n = orm.scalar( 

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

581 room_id=self._stored.id 

582 ) 

583 ) 

584 else: 

585 n = self._stored.n_participants 

586 

587 if n is not None: 

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

589 

590 if d := self._stored.description: 

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

592 

593 if s := self.subject: 

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

595 

596 if name := self.name: 

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

598 

599 if self._set_avatar_task is not None: 

600 await self._set_avatar_task 

601 avatar = self.get_avatar() 

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

603 form.add_field( 

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

605 ) 

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

607 if avatar.url: 

608 form.add_field("{http://slidge.im}/avatar#url", value=avatar.url) 

609 

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

611 form.add_field( 

612 "muc#roomconfig_whois", 

613 "list-single", 

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

615 ) 

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

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

618 

619 r = [form] 

620 

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

622 r.append(reaction_form) 

623 

624 if self._stored.space_id is not None: 

625 stored = self.__stored_space() 

626 assert stored is not None 

627 node = self._session.spaces.legacy_id_to_node(stored.legacy_id) 

628 iri = f"xmpp:{self.xmpp.boundjid.bare}?node={node}" 

629 form.add_field("muc#roominfo_pubsub", value=iri) 

630 space_form = self.xmpp.plugin["xep_0004"].make_form(ftype="result") 

631 space_form.add_field("FORM_TYPE", "hidden", value="urn:xmpp:spaces:0") 

632 space_form.add_field("parent", label="Space parent", value=iri) 

633 

634 return r 

635 

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

637 for r in self.get_user_resources(): 

638 j = JID(self.user_jid) 

639 j.resource = r 

640 yield j 

641 

642 @property 

643 def user_muc_jid(self) -> JID: 

644 return self.participants.get_user().jid 

645 

646 async def echo( 

647 self, msg: Message, legacy_msg_id: str | None = None, replace: str | None = None 

648 ) -> str: 

649 msg.set_from(self.user_muc_jid) 

650 if replace and not (legacy_msg_id or legacy_msg_id == replace): 

651 msg["stanza_id"]["id"] = uniquify_replace(replace) 

652 elif legacy_msg_id: 

653 msg["stanza_id"]["id"] = legacy_msg_id 

654 else: 

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

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

657 

658 user_part = self.participants.get_user() 

659 msg["occupant-id"]["id"] = user_part.occupant_id 

660 

661 self.archive.add(msg, user_part, legacy_msg_id=legacy_msg_id) 

662 

663 for user_full_jid in self.user_full_jids(): 

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

665 msg = copy(msg) 

666 msg.set_to(user_full_jid) 

667 

668 msg.send() 

669 

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

671 

672 def _post_image_update( 

673 self, cached_avatar: CachedAvatar | None, *, attr: str = "avatar" 

674 ) -> None: 

675 self.__send_configuration_change((104,)) 

676 self._send_room_presence() 

677 

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

679 tos = self.user_full_jids() if user_full_jid is None else [user_full_jid] 

680 for to in tos: 

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

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

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

684 if avatar.http_metadata is not None: 

685 metadata = self.xmpp.plugin["xep_0084"].stanza.MetaData() 

686 metadata.append(avatar.http_metadata) 

687 p.append(metadata) 

688 else: 

689 p["vcard_temp_update"]["photo"] = "" 

690 p.send() 

691 

692 @timeit 

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

694 user_full_jid = join_presence.get_from() 

695 requested_nickname = join_presence.get_to().resource 

696 client_resource = user_full_jid.resource 

697 

698 if client_resource in self.get_user_resources(): 

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

700 

701 if not requested_nickname or not client_resource: 

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

703 

704 self.add_user_resource(client_resource) 

705 

706 self.log.debug( 

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

708 client_resource, 

709 self.user_jid, 

710 self.legacy_id, 

711 requested_nickname, 

712 ) 

713 

714 await self.participants.ensure_filled(with_presence=True) 

715 self._stored.load_participants(self.__orm, with_presence=True) 

716 if "mav" in join_presence["muc_join"]: 

717 mav_until = await self.__get_mav() 

718 self.log.debug("client uses Room affiliation versioning") 

719 if join_presence["muc_join"]["mav"]["since"] != mav_until: 

720 self.log.debug( 

721 "client mav: %s vs our mav: %s", 

722 join_presence["muc_join"]["mav"]["since"], 

723 mav_until, 

724 ) 

725 await self.__send_mav(user_full_jid, mav_until) 

726 else: 

727 mav_until = None 

728 

729 self.log.debug("Sending presences for join of %s", client_resource) 

730 

731 self._stored.load_participants(self.xmpp.store.session, with_presence=True) 

732 user_participant = None 

733 

734 for db_participant in self._stored.participants: 

735 participant = self.participants._from_store(db_participant) 

736 if participant.is_user: 

737 user_participant = participant 

738 continue 

739 participant.send_initial_presence(full_jid=user_full_jid) 

740 

741 if user_participant is None: 

742 user_participant = self.participants.get_user() 

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

744 orm.add(self._stored) 

745 with orm.no_autoflush: 

746 orm.refresh(self._stored, ["participants"]) 

747 if not user_participant.is_user: 

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

749 user_participant.is_user = True 

750 user_participant.send_initial_presence( 

751 user_full_jid, 

752 presence_id=join_presence["id"], 

753 nick_change=user_participant.jid.resource != requested_nickname, 

754 mav_until=mav_until, 

755 ) 

756 

757 self.log.debug("Sending presences for join of %s: DONE", client_resource) 

758 

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

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

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

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

763 try: 

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

765 except ValueError: 

766 since = None 

767 if seconds is not None: 

768 since = datetime.now(tz=UTC) - timedelta(seconds=seconds) 

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

770 log.debug( 

771 "Joining client does not want any old-school Room history-on-join" 

772 ) 

773 else: 

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

775 await self.__fill_history() 

776 await self.__old_school_history( 

777 user_full_jid, 

778 maxchars=maxchars, 

779 maxstanzas=maxstanzas, 

780 since=since, 

781 ) 

782 if self.HAS_SUBJECT: 

783 subject = self.subject or "" 

784 else: 

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

786 self.__get_subject_setter_participant().set_room_subject( 

787 subject, 

788 user_full_jid, 

789 self.subject_date, 

790 update_muc=False, 

791 ) 

792 if t := self._set_avatar_task: 

793 await t 

794 self._send_room_presence(user_full_jid) 

795 

796 async def __get_mav(self) -> str: 

797 data = self.__get_mav_data() 

798 return self.__compute_mav_ver(data) 

799 

800 def __get_mav_data(self) -> list[tuple[str, MucAffiliation]]: 

801 data: list[tuple[str, MucAffiliation]] = [] 

802 

803 self._stored.load_participants(self.__orm, with_presence=False) 

804 for part in self._stored.participants: 

805 if not (part.is_user or part.contact): 

806 continue 

807 if part.affiliation == "none": 

808 continue 

809 data.append((self.__get_part_mav_id(part), part.affiliation)) 

810 return data 

811 

812 def __get_part_mav_id(self, part: LegacyParticipantT | Participant) -> str: 

813 return str(self.user_jid if part.is_user else part.contact.legacy_id) # type:ignore[union-attr] # ty:ignore[unresolved-attribute] 

814 

815 def __compute_mav_ver(self, data: list[tuple[Any, MucAffiliation]]) -> str: 

816 self.log.debug("MAV data: %s", data) 

817 affs = [] 

818 for id_, aff in data: 

819 if aff == "none": 

820 continue 

821 affs.append(f"{id_}\0{aff}".encode()) 

822 affs.sort() 

823 return hashlib.sha256(b"\0".join(affs)).hexdigest() 

824 

825 async def __send_mav(self, full_jid: JID, until: str) -> None: 

826 self.log.debug("Sending MAV") 

827 msg = self.xmpp.make_message(mto=full_jid, mfrom=self.jid) 

828 msg["muc"]["mav"]["until"] = until 

829 for part in self._stored.participants: 

830 if not (part.is_user or part.contact): 

831 continue 

832 item = MUCUserItem() 

833 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare # type:ignore[union-attr] 

834 item["affiliation"] = part.affiliation 

835 msg["muc"].append(item) 

836 msg.send() 

837 

838 def send_affiliation_change( 

839 self, part: LegacyParticipantT, was: MucAffiliation = "none" 

840 ) -> None: 

841 # internal use by slidge 

842 if not self.participants_filled: 

843 return 

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

845 return 

846 if self.get_lock("fill history"): 

847 return 

848 if part.contact is None: 

849 return 

850 if part.is_system: 

851 return 

852 if was == part.affiliation: 

853 return 

854 system_part = self.get_system_participant() 

855 msg = system_part._make_message(mtype="normal") 

856 until = self.__get_mav_data() 

857 since = [ 

858 (id_, was if id_ == self.__get_part_mav_id(part) else aff) 

859 for id_, aff in until 

860 ] 

861 if part.affiliation == "none": 

862 since.append((self.__get_part_mav_id(part), was)) 

863 msg["muc"]["mav"]["since"] = self.__compute_mav_ver(since) 

864 msg["muc"]["mav"]["until"] = self.__compute_mav_ver(until) 

865 item = MUCUserItem() 

866 item["affiliation"] = part.affiliation 

867 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare 

868 msg["muc"].append(item) 

869 system_part._send(msg) 

870 

871 def get_system_participant(self) -> LegacyParticipantT: 

872 """ 

873 Get a pseudo-participant, representing the room itself 

874 

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

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

877 service 

878 :return: 

879 """ 

880 return self._participant_cls( 

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

882 ) 

883 

884 def remove_participant( 

885 self, 

886 p: LegacyParticipantT, 

887 kick: bool = False, 

888 ban: bool = False, 

889 reason: str | None = None, 

890 ) -> None: 

891 """ 

892 Call this when a participant leaves the room 

893 

894 :param p: The participant 

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

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

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

898 """ 

899 self.log.debug("Removing participant: %s", p) 

900 if kick and ban: 

901 raise TypeError("Either kick or ban") 

902 if kick: 

903 codes = {307} 

904 elif ban: 

905 codes = {301} 

906 else: 

907 codes = None 

908 was = p._stored.affiliation 

909 p._stored.affiliation = "outcast" if ban else "none" 

910 p._stored.role = "none" 

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

912 with self.__orm() as orm: 

913 self.xmpp.store.participants.delete(orm, p._stored.id) 

914 orm.commit() 

915 self._stored.delete_participant(p._stored) 

916 self.send_affiliation_change(p, was) 

917 if reason: 

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

919 p._send(presence, force=True) 

920 

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

922 part = self.participants.get_by_nick(old_nickname) 

923 if part is None: 

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

925 return 

926 part.nickname = new_nickname 

927 

928 async def __old_school_history( 

929 self, 

930 full_jid: JID, 

931 maxchars: int | None = None, 

932 maxstanzas: int | None = None, 

933 seconds: int | None = None, 

934 since: datetime | None = None, 

935 ) -> None: 

936 """ 

937 Old-style history join (internal slidge use) 

938 

939 :param full_jid: 

940 :param maxchars: 

941 :param maxstanzas: 

942 :param seconds: 

943 :param since: 

944 :return: 

945 """ 

946 if since is None: 

947 if seconds is None: 

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

949 else: 

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

951 else: 

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

953 

954 for h_msg in self.archive.get_all( 

955 start_date=start_date, end_date=None, last_page_n=maxstanzas 

956 ): 

957 msg = h_msg.stanza("jabber:component:accept", user_jid=self.user_jid.bare) 

958 msg["delay"]["stamp"] = h_msg.timestamp.replace(tzinfo=UTC) 

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

960 msg.set_to(full_jid) 

961 self.xmpp.send(msg, False) 

962 

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

964 await self.__fill_history() 

965 

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

967 

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

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

970 

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

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

973 

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

975 

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

977 try: 

978 max_results = int(max_str) 

979 except ValueError: 

980 max_results = None 

981 else: 

982 max_results = None 

983 

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

985 after_id = after_id_rsm or after_id 

986 

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

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

989 last_page_n = max_results 

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

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

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

993 if before_rsm is not True: 

994 before_id = before_rsm 

995 else: 

996 last_page_n = None 

997 

998 first = None 

999 last = None 

1000 count = 0 

1001 

1002 sender = form_values.get("with") 

1003 sender_resource = JID(sender).resource if sender else None 

1004 

1005 it = self.archive.get_all( 

1006 start_date, 

1007 end_date, 

1008 before_id, 

1009 after_id, 

1010 ids, 

1011 last_page_n, 

1012 sender_resource, 

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

1014 ) 

1015 

1016 for history_msg in it: 

1017 last = xmpp_id = history_msg.stanza_id 

1018 if first is None: 

1019 first = xmpp_id 

1020 

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

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

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

1024 wrapper_msg["mam_result"].append( 

1025 history_msg.forwarded(user_jid=self.user_jid.bare) 

1026 ) 

1027 

1028 wrapper_msg.send() 

1029 count += 1 

1030 

1031 if max_results and count == max_results: 

1032 break 

1033 

1034 if max_results: 

1035 try: 

1036 next(it) 

1037 except StopIteration: 

1038 complete = True 

1039 else: 

1040 complete = False 

1041 else: 

1042 complete = True 

1043 

1044 reply = iq.reply() 

1045 if not self.STABLE_ARCHIVE: 

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

1047 if complete: 

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

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

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

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

1052 reply.send() 

1053 

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

1055 await self.__fill_history() 

1056 await self.archive.send_metadata(iq) 

1057 

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

1059 """ 

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

1061 

1062 :param r: The resource to kick 

1063 """ 

1064 pto = JID(self.user_jid) 

1065 pto.resource = r 

1066 p = self.xmpp.make_presence(pfrom=(self.participants.get_user()).jid, pto=pto) 

1067 p["type"] = "unavailable" 

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

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

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

1071 p.send() 

1072 

1073 def _on_delete(self) -> None: 

1074 self._session.create_task( 

1075 self._session.rooms._pep_del(self), f"Remove {self} from PEP bookmarks" 

1076 ) 

1077 

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

1079 """ 

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

1081 client. 

1082 

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

1084 unique identifier. In this case the Room avatar will be immediately 

1085 updated on the XMPP side. 

1086 

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

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

1089 room update event. 

1090 

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

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

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

1094 correct. 

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

1096 :py:meth:`slidge.group.room.LegacyRoom.set_avatar`. Alternatively, None, if 

1097 :py:meth:`.LegacyRoom.set_avatar` is meant to be awaited somewhere else. 

1098 """ 

1099 raise NotImplementedError 

1100 

1101 async def on_set_config( 

1102 self, 

1103 name: str | None, 

1104 description: str | None, 

1105 ) -> None: 

1106 """ 

1107 Triggered when the user requests changing the room configuration. 

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

1109 

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

1111 :attr:`LegacyRoom.description` of this instance. 

1112 

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

1114 be ``None``. 

1115 

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

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

1118 """ 

1119 raise NotImplementedError 

1120 

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

1122 """ 

1123 Triggered when the user requests room destruction. 

1124 

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

1126 """ 

1127 raise NotImplementedError 

1128 

1129 async def parse_mentions( 

1130 self, text: str | None 

1131 ) -> tuple[Mention[LegacyParticipantT], ...]: 

1132 if not text: 

1133 return () 

1134 await self.participants.ensure_filled() 

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

1136 orm.add(self._stored) 

1137 participants = { 

1138 p.nickname: p for p in self._stored.participants if len(p.nickname) > 1 

1139 } 

1140 

1141 if len(participants) == 0: 

1142 return () 

1143 

1144 result = [] 

1145 for match in re.finditer( 

1146 "|".join( 

1147 sorted( 

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

1149 key=lambda nick: len(nick), 

1150 reverse=True, 

1151 ) 

1152 ), 

1153 text, 

1154 ): 

1155 span = match.span() 

1156 nick = match.group() 

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

1158 continue 

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

1160 participant = self.participants._from_store( 

1161 stored=participants[nick] 

1162 ) 

1163 result.append( 

1164 Mention(participant=participant, start=span[0], end=span[1]) 

1165 ) 

1166 return tuple(result) 

1167 

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

1169 """ 

1170 Triggered when the user requests changing the room subject. 

1171 

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

1173 instance. 

1174 

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

1176 """ 

1177 raise NotImplementedError 

1178 

1179 async def on_set_thread_subject(self, thread: str, subject: str) -> None: 

1180 """ 

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

1182 

1183 :param thread: Legacy identifier of the thread 

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

1185 """ 

1186 raise NotImplementedError 

1187 

1188 async def on_moderate(self, legacy_msg_id: str, reason: str | None) -> None: 

1189 """ 

1190 Triggered when the user attempts to retract a message that was sent in 

1191 a Room using :xep:`0425`. 

1192 

1193 If retraction is not possible, this should raise the appropriate 

1194 XMPPError with a human-readable message. 

1195 

1196 NB: the legacy module is responsible for calling 

1197 :func:`LegacyParticipant.moderate` when this is successful, because 

1198 slidge will acknowledge the moderation IQ, but will not send the 

1199 moderation message from the Room automatically. 

1200 

1201 :param legacy_msg_id: The legacy ID of the message to be retracted 

1202 :param reason: Optionally, a reason for the moderation, given by the 

1203 user-moderator. 

1204 """ 

1205 raise NotImplementedError 

1206 

1207 async def on_leave(self) -> None: 

1208 """ 

1209 Triggered when the user leaves a group via the dedicated slidge command 

1210 or the :xep:`0077` ``<remove />`` mechanism. 

1211 

1212 This should be interpreted as definitely leaving the group. 

1213 """ 

1214 raise NotImplementedError 

1215 

1216 @property 

1217 def participants_filled(self) -> bool: 

1218 return self._stored.participants_filled 

1219 

1220 def get_archived_messages( 

1221 self, msg_id: str | list[str] 

1222 ) -> Iterator[ArchivedMessage]: 

1223 """ 

1224 Query the slidge archive for messages sent in this group 

1225 

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

1227 or an XMPP ID. 

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

1229 because of multi-attachment messages. 

1230 """ 

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

1232 yield from self.xmpp.store.mam.get_messages( 

1233 orm, 

1234 self._stored.id, 

1235 ids=[str(msg_id)] if isinstance(msg_id, str) else msg_id, 

1236 ) 

1237 

1238 

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

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

1241 sub.attrib["id"] = origin_id 

1242 msg.xml.append(sub) 

1243 

1244 

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

1246 try: 

1247 return int(x) 

1248 except ValueError: 

1249 return None 

1250 

1251 

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

1253 if x is None: 

1254 return False 

1255 else: 

1256 return x == 0 

1257 

1258 

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

1260 if date is None: 

1261 return None 

1262 try: 

1263 return str_to_datetime(date) 

1264 except ValueError: 

1265 return None 

1266 

1267 

1268LegacyRoom._participant_cls = LegacyParticipant # type:ignore[misc] 

1269 

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

1271 

1272log = logging.getLogger(__name__)