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

371 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-10 04:45 +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 if a.http_metadata is not None: 

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

326 metadata.append(a.http_metadata) 

327 p.append(metadata) 

328 else: 

329 warnings.warn( 

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

331 ) 

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

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

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

335 return p 

336 

337 @property 

338 def DISCO_NAME(self) -> str: 

339 return self.nickname 

340 

341 @DISCO_NAME.setter 

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

343 raise RuntimeError 

344 

345 def __send_presence_if_needed( 

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

347 ) -> None: 

348 if ( 

349 archive_only 

350 or self.is_system 

351 or self.is_user 

352 or self._presence_sent 

353 or stanza["subject"] 

354 ): 

355 return 

356 if isinstance(stanza, Message): 

357 if "muc" in stanza: 

358 return 

359 self.send_initial_presence(full_jid) 

360 

361 @property 

362 def occupant_id(self) -> str: 

363 return self.stored.occupant_id 

364 

365 def _send( 

366 self, 

367 stanza: MessageOrPresenceTypeVar, 

368 full_jid: JID | None = None, 

369 archive_only: bool = False, 

370 legacy_msg_id: str | None = None, 

371 force: bool = False, 

372 **send_kwargs: Any, # noqa:ANN401 

373 ) -> MessageOrPresenceTypeVar: 

374 if stanza.get_from().resource: 

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

376 else: 

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

378 self.__add_nick_element(stanza) 

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

380 if ( 

381 not force 

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

383 and not self._presence_sent 

384 ): 

385 return stanza 

386 self._presence_sent = True 

387 if full_jid: 

388 stanza["to"] = full_jid 

389 self.__send_presence_if_needed(stanza, full_jid, archive_only) 

390 if self.is_user: 

391 assert stanza.stream is not None 

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

393 else: 

394 stanza.send() 

395 else: 

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

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

398 if archive_only: 

399 return stanza 

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

401 stanza = copy(stanza) 

402 stanza["to"] = user_full_jid 

403 self.__send_presence_if_needed(stanza, user_full_jid, archive_only) 

404 stanza.send() 

405 return stanza 

406 

407 def mucadmin_item(self) -> MUCAdminItem: 

408 item = MUCAdminItem() 

409 item["nick"] = self.nickname 

410 item["affiliation"] = self.affiliation 

411 item["role"] = self.role 

412 if not self.muc.is_anonymous: 

413 if self.is_user: 

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

415 elif self.contact: 

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

417 else: 

418 warnings.warn( 

419 ( 

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

421 f" {self}" 

422 ), 

423 ) 

424 return item 

425 

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

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

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

429 n["nick"] = nick 

430 stanza.append(n) 

431 

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

433 own = super()._get_last_presence() 

434 if own is None and self.contact: 

435 return self.contact._get_last_presence() 

436 return own 

437 

438 def send_initial_presence( 

439 self, 

440 full_jid: JID, 

441 nick_change: bool = False, 

442 presence_id: str | None = None, 

443 mav_until: str | None = None, 

444 ) -> None: 

445 """ 

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

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

448 

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

450 

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

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

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

454 """ 

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

456 codes = set() 

457 if nick_change: 

458 codes.add(210) 

459 

460 if self.is_user: 

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

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

463 kwargs = {} 

464 else: 

465 cache = self._get_last_presence() 

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

467 if cache: 

468 ptype = cache.ptype 

469 if ptype == "unavailable": 

470 return 

471 kwargs = { 

472 "last_seen": cache.last_seen, 

473 "pstatus": cache.pstatus, 

474 "pshow": cache.pshow, 

475 } 

476 else: 

477 kwargs = {} 

478 p = self._make_presence( 

479 status_codes=codes, 

480 user_full_jid=full_jid, 

481 **kwargs, # type:ignore 

482 ) 

483 if presence_id: 

484 p["id"] = presence_id 

485 if self.is_user and mav_until is not None: 

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

487 self._send(p, full_jid) 

488 

489 def leave(self) -> None: 

490 """ 

491 Call this when the participant leaves the room 

492 """ 

493 self.muc.remove_participant(self) 

494 

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

496 """ 

497 Call this when the participant is kicked from the room 

498 """ 

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

500 

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

502 """ 

503 Call this when the participant is banned from the room 

504 """ 

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

506 

507 async def get_disco_info( 

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

509 ) -> DiscoInfo: 

510 if self.contact is not None: 

511 return await self.contact.get_disco_info() 

512 return await super().get_disco_info() 

513 

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

515 for i in self._legacy_to_xmpp(legacy_msg_id): 

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

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

518 if self.is_system: 

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

520 else: 

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

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

523 if reason: 

524 m["retract"]["reason"] = reason 

525 self._send(m) 

526 

527 def set_room_subject( 

528 self, 

529 subject: str, 

530 full_jid: JID | None = None, 

531 when: datetime | None = None, 

532 update_muc: bool = True, 

533 ) -> None: 

534 if update_muc: 

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

536 self.muc.subject_setter = self.nickname 

537 self.muc.subject_date = when 

538 

539 msg = self._make_message() 

540 if when is not None: 

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

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

543 if subject: 

544 msg["subject"] = subject 

545 else: 

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

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

548 self._send(msg, full_jid) 

549 

550 def set_thread_subject( 

551 self, 

552 thread: str, 

553 subject: str | None, 

554 when: datetime | None = None, 

555 ) -> None: 

556 msg = self._make_message() 

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

558 if when is not None: 

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

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

561 if subject: 

562 msg["subject"] = subject 

563 else: 

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

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

566 self._send(msg) 

567 

568 async def on_set_affiliation( 

569 self, 

570 affiliation: MucAffiliation, 

571 reason: str | None, 

572 nickname: str | None, 

573 ) -> None: 

574 """ 

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

576 for this group. 

577 

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

579 

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

581 :param affiliation: The new affiliation 

582 :param reason: A reason for this affiliation change 

583 :param nickname: 

584 """ 

585 raise NotImplementedError 

586 

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

588 """ 

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

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

591 

592 :param contact: Contact to be kicked 

593 :param reason: A reason for this kick 

594 """ 

595 raise NotImplementedError 

596 

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

598 """ 

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

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

601 

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

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

604 behaviour. 

605 

606 :param muc: The group 

607 :param reason: Optionally, a reason 

608 """ 

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

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

611 

612 

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

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

615 

616 jid = JID(muc_jid) 

617 

618 try: 

619 jid.resource = nickname 

620 except InvalidJID: 

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

622 try: 

623 jid.resource = nickname 

624 except InvalidJID: 

625 # at this point there still might be control chars 

626 jid.resource = strip_non_printable(nickname) 

627 

628 return nickname_no_illegal, jid 

629 

630 

631log = logging.getLogger(__name__)