Coverage for slidge/group/participant.py: 87%

367 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-18 04:30 +0000

1import logging 

2import string 

3import uuid 

4import warnings 

5from copy import copy 

6from datetime import datetime 

7from typing import TYPE_CHECKING, Any, Literal 

8from xml.etree import ElementTree as ET 

9 

10import sqlalchemy as sa 

11from slixmpp import JID, InvalidJID, Message, Presence 

12from slixmpp.plugins.xep_0030.stanza.info import DiscoInfo 

13from slixmpp.plugins.xep_0045.stanza import MUCAdminItem 

14from slixmpp.plugins.xep_0492.stanza import Never 

15from slixmpp.types import MessageTypes, OptJid 

16from sqlalchemy.orm.exc import DetachedInstanceError 

17 

18from ..core.mixins import ChatterDiscoMixin, MessageMixin, PresenceMixin 

19from ..core.mixins.db import DBMixin 

20from ..db.models import Participant 

21from ..util import strip_illegal_chars 

22from ..util.types import ( 

23 AnyMUC, 

24 CachedPresence, 

25 Hat, 

26 MessageOrPresenceTypeVar, 

27 MucAffiliation, 

28 MucRole, 

29) 

30 

31if TYPE_CHECKING: 

32 from slidge.command.base import ContactCommand 

33 from slidge.contact import LegacyContact 

34 

35 

36def strip_non_printable(nickname: str) -> str: 

37 new = ( 

38 "".join(x for x in nickname if x in string.printable) 

39 + f"-slidge-{hash(nickname)}" 

40 ) 

41 warnings.warn(f"Could not use {nickname} as a nickname, using {new}") 

42 return new 

43 

44 

45class LegacyParticipant[LegacyContactType: "LegacyContact"]( 

46 PresenceMixin, 

47 MessageMixin, 

48 ChatterDiscoMixin, 

49 DBMixin, 

50): 

51 """ 

52 A legacy participant of a legacy group chat. 

53 """ 

54 

55 is_participant: Literal[True] = True 

56 

57 mtype: MessageTypes = "groupchat" 

58 _can_send_carbon = False 

59 USE_STANZA_ID = True 

60 STRIP_SHORT_DELAY = False 

61 stored: Participant 

62 contact: LegacyContactType | None 

63 

64 def __init__( 

65 self, 

66 muc: AnyMUC, 

67 stored: Participant, 

68 is_system: bool = False, 

69 contact: LegacyContactType | None = None, 

70 ) -> None: 

71 self.muc = muc 

72 self.session = muc.session 

73 self.xmpp = muc.session.xmpp 

74 self.is_system = is_system 

75 

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

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

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

79 stored.contact = contact.stored 

80 

81 self.stored = stored 

82 self.contact = contact 

83 

84 super().__init__() 

85 

86 if stored.resource is None: 

87 self.__update_resource(stored.nickname) 

88 

89 self.log = logging.getLogger(f"{self.user_jid.bare}:{self.jid}") 

90 

91 def _recipient_pk(self) -> int: 

92 return self.muc.stored.id 

93 

94 def __eq__(self, other: object) -> bool: 

95 return isinstance(other, LegacyParticipant) and self.jid == other.jid 

96 

97 @property 

98 def is_user(self) -> bool: 

99 try: 

100 return self.stored.is_user 

101 except DetachedInstanceError: 

102 self.merge() 

103 return self.stored.is_user 

104 

105 @is_user.setter 

106 def is_user(self, is_user: bool) -> None: 

107 with self.xmpp.store.session(expire_on_commit=True) as orm: 

108 orm.add(self.stored) 

109 self.stored.is_user = is_user 

110 orm.commit() 

111 

112 @property 

113 def jid(self) -> JID: 

114 jid = JID(self.muc.jid) 

115 if self.stored.resource: 

116 jid.resource = self.stored.resource 

117 return jid 

118 

119 @jid.setter 

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

121 # FIXME: without this, mypy yields 

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

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

124 raise RuntimeError 

125 

126 @property 

127 def commands(self) -> dict[str, "type[ContactCommand[Any]]"]: # type:ignore[override] 

128 if self.contact is None: 

129 return {} 

130 else: 

131 return self.contact.commands 

132 

133 def __should_commit(self) -> bool: 

134 if self.is_system: 

135 return False 

136 if self.muc.get_lock("fill participants"): 

137 return False 

138 return not self.muc.get_lock("fill history") 

139 

140 def commit(self) -> None: 

141 if not self.__should_commit(): 

142 return 

143 super().commit() 

144 

145 def __repr__(self) -> str: 

146 return f"<Participant '{self.nickname}'/'{self.jid}' of '{self.muc}'>" 

147 

148 @property 

149 def _presence_sent(self) -> bool: 

150 # we track if we already sent a presence for this participant. 

151 # if we didn't, we send it before the first message. 

152 # this way, event in plugins that don't map "user has joined" events, 

153 # we send a "join"-presence from the participant before the first message 

154 return self.stored.presence_sent 

155 

156 @_presence_sent.setter 

157 def _presence_sent(self, val: bool) -> None: 

158 if self._presence_sent == val: 

159 return 

160 self.stored.presence_sent = val 

161 if not self.__should_commit(): 

162 return 

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

164 orm.execute( 

165 sa.update(Participant) 

166 .where(Participant.id == self.stored.id) 

167 .values(presence_sent=val) 

168 ) 

169 orm.commit() 

170 

171 @property 

172 def nickname_no_illegal(self) -> str: 

173 return self.stored.nickname_no_illegal 

174 

175 @property 

176 def affiliation(self) -> MucAffiliation: 

177 return self.stored.affiliation 

178 

179 @affiliation.setter 

180 def affiliation(self, affiliation: MucAffiliation) -> None: 

181 if self.affiliation == affiliation: 

182 return 

183 was = self.stored.affiliation 

184 self.stored.affiliation = affiliation 

185 if not self.muc.participants_filled: 

186 return 

187 self.commit() 

188 if self.cached_presence is None or self.cached_presence.ptype == "unavailable": 

189 self.muc.send_affiliation_change(self, was) 

190 self.send_last_presence(force=True, no_cache_online=True) 

191 

192 @property 

193 def role(self) -> MucRole: 

194 return self.stored.role 

195 

196 @role.setter 

197 def role(self, role: MucRole) -> None: 

198 if self.role == role: 

199 return 

200 self.stored.role = role 

201 if not self.muc.participants_filled: 

202 return 

203 self.commit() 

204 if not self._presence_sent: 

205 return 

206 self.send_last_presence(force=True, no_cache_online=True) 

207 

208 @property 

209 def hats(self) -> list[Hat]: 

210 return [Hat(*h) for h in self.stored.hats] if self.stored.hats else [] 

211 

212 def set_hats(self, hats: list[Hat]) -> None: 

213 if self.hats == hats: 

214 return 

215 self.stored.hats = hats 

216 if not self.muc.participants_filled: 

217 return 

218 self.commit() 

219 if not self._presence_sent: 

220 return 

221 self.send_last_presence(force=True, no_cache_online=True) 

222 

223 def __update_resource(self, unescaped_nickname: str | None) -> None: 

224 if not unescaped_nickname: 

225 self.stored.resource = "" 

226 if self.is_system: 

227 self.stored.nickname_no_illegal = "" 

228 else: 

229 warnings.warn( 

230 "Only the system participant is allowed to not have a nickname" 

231 ) 

232 nickname = f"unnamed-{uuid.uuid4()}" 

233 self.stored.resource = self.stored.nickname_no_illegal = nickname 

234 return 

235 

236 self.stored.nickname_no_illegal, jid = escape_nickname( 

237 self.muc.jid, 

238 unescaped_nickname, 

239 ) 

240 self.stored.resource = jid.resource 

241 

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

243 if not self.is_system: 

244 raise RuntimeError("This is only possible for the system participant") 

245 msg = self._make_message() 

246 msg["muc"]["status_codes"] = codes 

247 self._send(msg) 

248 

249 @property 

250 def nickname(self) -> str: 

251 return self.stored.nickname 

252 

253 @nickname.setter 

254 def nickname(self, new_nickname: str) -> None: 

255 old = self.nickname 

256 if new_nickname == old: 

257 return 

258 

259 if self.muc.stored.id is not None: 

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

261 if not self.xmpp.store.rooms.nick_available( 

262 orm, self.muc.stored.id, new_nickname 

263 ): 

264 if self.contact is None: 

265 new_nickname = f"{new_nickname} ({self.occupant_id})" 

266 else: 

267 new_nickname = f"{new_nickname} ({self.contact.legacy_id})" 

268 

269 cache = getattr(self, "_last_presence", None) 

270 if cache: 

271 last_seen = cache.last_seen 

272 kwargs = cache.presence_kwargs 

273 else: 

274 last_seen = None 

275 kwargs = {} 

276 

277 kwargs["status_codes"] = {303} 

278 

279 p = self._make_presence(ptype="unavailable", last_seen=last_seen, **kwargs) 

280 # in this order so pfrom=old resource and we actually use the escaped nick 

281 # in the muc/item/nick element 

282 self.__update_resource(new_nickname) 

283 p["muc"]["item"]["nick"] = self.jid.resource 

284 self._send(p) 

285 

286 self.stored.nickname = new_nickname 

287 self.commit() 

288 kwargs["status_codes"] = set() 

289 p = self._make_presence(ptype="available", last_seen=last_seen, **kwargs) 

290 self._send(p) 

291 

292 def _make_presence( # type:ignore[no-untyped-def] 

293 self, 

294 *, 

295 last_seen: datetime | None = None, 

296 status_codes: set[int] | None = None, 

297 user_full_jid: JID | None = None, 

298 **presence_kwargs, # noqa type:ignore[no-untyped-def] 

299 ) -> Presence: 

300 p = super()._make_presence(last_seen=last_seen, **presence_kwargs) 

301 p["muc"]["affiliation"] = self.affiliation 

302 p["muc"]["role"] = self.role 

303 if self.hats: 

304 p["hats"].add_hats(self.hats) 

305 codes = status_codes or set() 

306 if self.is_user: 

307 codes.add(110) 

308 if not self.muc.is_anonymous and not self.is_system: 

309 if self.is_user: 

310 if user_full_jid: 

311 p["muc"]["jid"] = user_full_jid 

312 else: 

313 jid = JID(self.user_jid) 

314 try: 

315 jid.resource = next(iter(self.muc.get_user_resources())) 

316 except StopIteration: 

317 jid.resource = "pseudo-resource" 

318 p["muc"]["jid"] = self.user_jid 

319 codes.add(100) 

320 elif self.contact: 

321 p["muc"]["jid"] = self.contact.jid 

322 if a := self.contact.get_avatar(): 

323 p["vcard_temp_update"]["photo"] = a.id 

324 else: 

325 warnings.warn( 

326 f"Private group but no 1:1 JID associated to '{self}'", 

327 ) 

328 if self.is_user and (hash_ := self.session.user.avatar_hash): 

329 p["vcard_temp_update"]["photo"] = hash_ 

330 p["muc"]["status_codes"] = codes 

331 return p 

332 

333 @property 

334 def DISCO_NAME(self) -> str: 

335 return self.nickname 

336 

337 @DISCO_NAME.setter 

338 def DISCO_NAME(self, _: str) -> Never: 

339 raise RuntimeError 

340 

341 def __send_presence_if_needed( 

342 self, stanza: Message | Presence, full_jid: JID, archive_only: bool 

343 ) -> None: 

344 if ( 

345 archive_only 

346 or self.is_system 

347 or self.is_user 

348 or self._presence_sent 

349 or stanza["subject"] 

350 ): 

351 return 

352 if isinstance(stanza, Message): 

353 if "muc" in stanza: 

354 return 

355 self.send_initial_presence(full_jid) 

356 

357 @property 

358 def occupant_id(self) -> str: 

359 return self.stored.occupant_id 

360 

361 def _send( 

362 self, 

363 stanza: MessageOrPresenceTypeVar, 

364 full_jid: JID | None = None, 

365 archive_only: bool = False, 

366 legacy_msg_id: str | None = None, 

367 force: bool = False, 

368 **send_kwargs: Any, # noqa:ANN401 

369 ) -> MessageOrPresenceTypeVar: 

370 if stanza.get_from().resource: 

371 stanza["occupant-id"]["id"] = self.occupant_id 

372 else: 

373 stanza["occupant-id"]["id"] = "room" 

374 self.__add_nick_element(stanza) 

375 if not self.is_user and isinstance(stanza, Presence): 

376 if ( 

377 not force 

378 and stanza["type"] == "unavailable" 

379 and not self._presence_sent 

380 ): 

381 return stanza 

382 self._presence_sent = True 

383 if full_jid: 

384 stanza["to"] = full_jid 

385 self.__send_presence_if_needed(stanza, full_jid, archive_only) 

386 if self.is_user: 

387 assert stanza.stream is not None 

388 stanza.stream.send(stanza, use_filters=False) 

389 else: 

390 stanza.send() 

391 else: 

392 if hasattr(self.muc, "archive") and isinstance(stanza, Message): 

393 self.muc.archive.add(stanza, self, archive_only, legacy_msg_id) 

394 if archive_only: 

395 return stanza 

396 for user_full_jid in self.muc.user_full_jids(): 

397 stanza = copy(stanza) 

398 stanza["to"] = user_full_jid 

399 self.__send_presence_if_needed(stanza, user_full_jid, archive_only) 

400 stanza.send() 

401 return stanza 

402 

403 def mucadmin_item(self) -> MUCAdminItem: 

404 item = MUCAdminItem() 

405 item["nick"] = self.nickname 

406 item["affiliation"] = self.affiliation 

407 item["role"] = self.role 

408 if not self.muc.is_anonymous: 

409 if self.is_user: 

410 item["jid"] = self.user_jid.bare 

411 elif self.contact: 

412 item["jid"] = self.contact.jid.bare 

413 else: 

414 warnings.warn( 

415 ( 

416 f"Private group but no contact JID associated to {self.jid} in" 

417 f" {self}" 

418 ), 

419 ) 

420 return item 

421 

422 def __add_nick_element(self, stanza: Presence | Message) -> None: 

423 if (nick := self.nickname_no_illegal) != self.jid.resource: 

424 n = self.xmpp.plugin["xep_0172"].stanza.UserNick() 

425 n["nick"] = nick 

426 stanza.append(n) 

427 

428 def _get_last_presence(self) -> CachedPresence | None: 

429 own = super()._get_last_presence() 

430 if own is None and self.contact: 

431 return self.contact._get_last_presence() 

432 return own 

433 

434 def send_initial_presence( 

435 self, 

436 full_jid: JID, 

437 nick_change: bool = False, 

438 presence_id: str | None = None, 

439 mav_until: str | None = None, 

440 ) -> None: 

441 """ 

442 Called when the user joins a MUC, as a mechanism 

443 to indicate to the joining XMPP client the list of "participants". 

444 

445 Can be called this to trigger a "participant has joined the group" event. 

446 

447 :param full_jid: Set this to only send to a specific user XMPP resource. 

448 :param nick_change: Used when the user joins and the MUC renames them (code 210) 

449 :param presence_id: set the presence ID. used internally by slidge 

450 """ 

451 # MUC status codes: https://xmpp.org/extensions/xep-0045.html#registrar-statuscodes 

452 codes = set() 

453 if nick_change: 

454 codes.add(210) 

455 

456 if self.is_user: 

457 # the "initial presence" of the user has to be vanilla, as it is 

458 # a crucial part of the MUC join sequence for XMPP clients. 

459 kwargs = {} 

460 else: 

461 cache = self._get_last_presence() 

462 self.log.debug("Join muc, initial presence: %s", cache) 

463 if cache: 

464 ptype = cache.ptype 

465 if ptype == "unavailable": 

466 return 

467 kwargs = { 

468 "last_seen": cache.last_seen, 

469 "pstatus": cache.pstatus, 

470 "pshow": cache.pshow, 

471 } 

472 else: 

473 kwargs = {} 

474 p = self._make_presence( 

475 status_codes=codes, 

476 user_full_jid=full_jid, 

477 **kwargs, # type:ignore 

478 ) 

479 if presence_id: 

480 p["id"] = presence_id 

481 if self.is_user and mav_until is not None: 

482 p["muc"]["mav"]["until"] = mav_until 

483 self._send(p, full_jid) 

484 

485 def leave(self) -> None: 

486 """ 

487 Call this when the participant leaves the room 

488 """ 

489 self.muc.remove_participant(self) 

490 

491 def kick(self, reason: str | None = None) -> None: 

492 """ 

493 Call this when the participant is kicked from the room 

494 """ 

495 self.muc.remove_participant(self, kick=True, reason=reason) 

496 

497 def ban(self, reason: str | None = None) -> None: 

498 """ 

499 Call this when the participant is banned from the room 

500 """ 

501 self.muc.remove_participant(self, ban=True, reason=reason) 

502 

503 async def get_disco_info( 

504 self, jid: OptJid = None, node: str | None = None 

505 ) -> DiscoInfo: 

506 if self.contact is not None: 

507 return await self.contact.get_disco_info() 

508 return await super().get_disco_info() 

509 

510 def moderate(self, legacy_msg_id: str, reason: str | None = None) -> None: 

511 for i in self._legacy_to_xmpp(legacy_msg_id): 

512 m = self.muc.get_system_participant()._make_message() 

513 m["retract"]["id"] = i 

514 if self.is_system: 

515 m["retract"].enable("moderated") 

516 else: 

517 m["retract"]["moderated"]["by"] = self.jid 

518 m["retract"]["moderated"]["occupant-id"]["id"] = self.occupant_id 

519 if reason: 

520 m["retract"]["reason"] = reason 

521 self._send(m) 

522 

523 def set_room_subject( 

524 self, 

525 subject: str, 

526 full_jid: JID | None = None, 

527 when: datetime | None = None, 

528 update_muc: bool = True, 

529 ) -> None: 

530 if update_muc: 

531 self.muc._subject = subject # type: ignore 

532 self.muc.subject_setter = self.nickname 

533 self.muc.subject_date = when 

534 

535 msg = self._make_message() 

536 if when is not None: 

537 msg["delay"].set_stamp(when) 

538 msg["delay"]["from"] = self.muc.jid 

539 if subject: 

540 msg["subject"] = subject 

541 else: 

542 # may be simplified if slixmpp lets it do it more easily some day 

543 msg.xml.append(ET.Element(f"{{{msg.namespace}}}subject")) 

544 self._send(msg, full_jid) 

545 

546 def set_thread_subject( 

547 self, 

548 thread: str, 

549 subject: str | None, 

550 when: datetime | None = None, 

551 ) -> None: 

552 msg = self._make_message() 

553 msg["thread"] = str(thread) 

554 if when is not None: 

555 msg["delay"].set_stamp(when) 

556 msg["delay"]["from"] = self.muc.jid 

557 if subject: 

558 msg["subject"] = subject 

559 else: 

560 # may be simplified if slixmpp lets it do it more easily some day 

561 msg.xml.append(ET.Element(f"{{{msg.namespace}}}subject")) 

562 self._send(msg) 

563 

564 async def on_set_affiliation( 

565 self, 

566 affiliation: MucAffiliation, 

567 reason: str | None, 

568 nickname: str | None, 

569 ) -> None: 

570 """ 

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

572 for this group. 

573 

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

575 

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

577 :param affiliation: The new affiliation 

578 :param reason: A reason for this affiliation change 

579 :param nickname: 

580 """ 

581 raise NotImplementedError 

582 

583 async def on_kick(self, reason: str | None) -> None: 

584 """ 

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

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

587 

588 :param contact: Contact to be kicked 

589 :param reason: A reason for this kick 

590 """ 

591 raise NotImplementedError 

592 

593 async def on_invitation(self, reason: str | None) -> None: 

594 """ 

595 Triggered when the user invites this :term:`Contact <Legacy Contact>` 

596 to a legacy MUC via :xep:`0249`. 

597 

598 The default implementation calls :meth:`LegacyMUC.on_set_affiliation` 

599 with the 'member' affiliation. Override if you want to customize this 

600 behaviour. 

601 

602 :param muc: The group 

603 :param reason: Optionally, a reason 

604 """ 

605 # part = await self.muc.get_participant_by_contact(self) 

606 await self.on_set_affiliation("member", reason, None) 

607 

608 

609def escape_nickname(muc_jid: JID, nickname: str) -> tuple[str, JID]: 

610 nickname = nickname_no_illegal = strip_illegal_chars(nickname).replace("\n", " | ") 

611 

612 jid = JID(muc_jid) 

613 

614 try: 

615 jid.resource = nickname 

616 except InvalidJID: 

617 nickname = nickname.encode("punycode").decode() 

618 try: 

619 jid.resource = nickname 

620 except InvalidJID: 

621 # at this point there still might be control chars 

622 jid.resource = strip_non_printable(nickname) 

623 

624 return nickname_no_illegal, jid 

625 

626 

627log = logging.getLogger(__name__)