Coverage for slidge/room/participant.py: 93%

302 statements  

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

1import logging 

2import warnings 

3from copy import copy 

4from datetime import datetime 

5from typing import TYPE_CHECKING, Any, Literal 

6from xml.etree import ElementTree as ET 

7 

8from slixmpp import JID, Iq, Message, Presence 

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

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

11from slixmpp.plugins.xep_0045.stanza import MUCAdminItem 

12from slixmpp.plugins.xep_0492.stanza import Never 

13from slixmpp.types import MessageTypes, OptJid, PresenceShows 

14from typing_extensions import TypedDict 

15 

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

17from ..core.mixins.db import UpdateInfoMixin 

18from ..db.models import Participant 

19from ..util.types import ( 

20 AnyRoom, 

21 ExtraAttrMapping, 

22 Hat, 

23 MessageOrPresenceTypeVar, 

24 MucAffiliation, 

25 MucRole, 

26) 

27 

28if TYPE_CHECKING: 

29 from slidge.command.base import ContactCommand 

30 from slidge.contact import LegacyContact 

31 

32 

33class _ParticipantProps(TypedDict, total=False): 

34 affiliation: MucAffiliation 

35 role: MucRole 

36 hats: list[Hat] 

37 nickname: str 

38 pshow: PresenceShows | Literal["available"] | None 

39 pstatus: str | None 

40 last_seen: datetime | None 

41 

42 

43class LegacyParticipant[LegacyContactType: "LegacyContact", T: ExtraAttrMapping = Any]( 

44 PresenceMixin, 

45 MessageMixin, 

46 ChatterDiscoMixin, 

47 UpdateInfoMixin[T, _ParticipantProps], 

48): 

49 """ 

50 A legacy participant of a legacy group chat. 

51 """ 

52 

53 is_participant: Literal[True] = True 

54 

55 _mtype: MessageTypes = "groupchat" 

56 _can_send_carbon = False 

57 _USE_STANZA_ID = True 

58 _STRIP_SHORT_DELAY = False 

59 _stored: Participant 

60 contact: LegacyContactType | None 

61 

62 def __init__( 

63 self, 

64 muc: AnyRoom, 

65 stored: Participant, 

66 is_system: bool = False, 

67 contact: LegacyContactType | None = None, 

68 ) -> None: 

69 self._room = muc 

70 self._session = muc._session 

71 self.xmpp = muc._session.xmpp 

72 self.is_system = is_system 

73 

74 if contact is None: 

75 stored.ensure_relation("contact", self.xmpp.store.session) 

76 if stored.contact is not None: 

77 contact = self._session.contacts._from_store(stored.contact) 

78 

79 self._stored = stored 

80 self.contact = contact 

81 

82 if contact is not None: 

83 self._stored._set_committed_value("contact", contact._stored) 

84 

85 super().__init__() 

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

87 

88 def _recipient_pk(self) -> int: 

89 return self._room._stored.id 

90 

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

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

93 

94 @property 

95 def is_user(self) -> bool: 

96 return bool(self._stored.is_user) 

97 

98 @is_user.setter 

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

100 self._update_stored_attribute(is_user=is_user) 

101 

102 @property 

103 def jid(self) -> JID: 

104 jid = JID(self._room.jid) 

105 if self._stored.resource: 

106 jid.resource = self._stored.resource 

107 return jid 

108 

109 @property 

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

111 if self.contact is None: 

112 return {} 

113 else: 

114 return self.contact._commands 

115 

116 def __repr__(self) -> str: 

117 return f"<Participant '{self.nickname}'/'{self.jid}' of '{self._room}'>" 

118 

119 @property 

120 def affiliation(self) -> MucAffiliation: 

121 return self._stored.affiliation 

122 

123 @affiliation.setter 

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

125 was = self.affiliation 

126 if not self._update_stored_attribute(affiliation=affiliation): 

127 return 

128 if not self._room.participants_filled: 

129 return 

130 if self.contact and not self.contact.online: 

131 self._room._stored.update_participant(self._stored) 

132 self._room.send_affiliation_change(self, was) 

133 self.send_last_presence(force=True) 

134 

135 @property 

136 def role(self) -> MucRole: 

137 return self._stored.role 

138 

139 @role.setter 

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

141 if not self._update_stored_attribute(role=role): 

142 return 

143 if not self._room.participants_filled: 

144 return 

145 self.send_last_presence(force=True) 

146 

147 @property 

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

149 return [Hat(*h) for h in self._stored.hats] if self._stored.hats else [] 

150 

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

152 if not self._update_stored_attribute(hats=hats): 

153 return 

154 if not self._room.participants_filled: 

155 return 

156 

157 self.send_last_presence(force=True) 

158 

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

160 if not self.is_system: 

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

162 msg = self._make_message() 

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

164 self._send(msg) 

165 

166 @property 

167 def nickname(self) -> str: 

168 return self._stored.nickname 

169 

170 @nickname.setter 

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

172 old_jid = self.jid 

173 if not self._update_stored_attribute(nickname=new_nickname): 

174 return 

175 

176 self._room.participants._rename(self._stored, new_nickname, self.occupant_id) 

177 p = self._make_presence(ptype="unavailable", status_codes={303}) 

178 p.set_from(old_jid) 

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

180 self._send(p, force=True) 

181 p = self._make_presence(ptype="available", last_seen=self.last_seen) 

182 self._send(p, force=True) 

183 

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

185 self, 

186 *, 

187 last_seen: datetime | None = None, 

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

189 user_full_jid: JID | None = None, 

190 **presence_kwargs, # noqa 

191 ) -> Presence: 

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

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

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

195 if self.hats: 

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

197 codes = status_codes or set() 

198 if self.is_user: 

199 codes.add(110) 

200 if not self._room.is_anonymous and not self.is_system: 

201 if self.is_user: 

202 if user_full_jid: 

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

204 else: 

205 jid = JID(self.user_jid) 

206 try: 

207 jid.resource = next(iter(self._room.get_user_resources())) 

208 except StopIteration: 

209 jid.resource = "pseudo-resource" 

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

211 codes.add(100) 

212 elif self.contact: 

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

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

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

216 if a.http_metadata is not None: 

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

218 metadata.append(a.http_metadata) 

219 p.append(metadata) 

220 else: 

221 warnings.warn( 

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

223 ) 

224 if self.is_user and (hash_ := self._session.user.avatar_hash): 

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

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

227 return p 

228 

229 @property 

230 def DISCO_NAME(self) -> str: 

231 return self.nickname 

232 

233 @DISCO_NAME.setter 

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

235 raise RuntimeError 

236 

237 @property 

238 def occupant_id(self) -> str: 

239 if self.is_user: 

240 return "slidge-user" 

241 elif self.contact: 

242 return str(self.contact.jid) 

243 else: 

244 return self._stored.occupant_id or self.nickname 

245 

246 def _send( 

247 self, 

248 stanza: MessageOrPresenceTypeVar, 

249 full_jid: JID | None = None, 

250 legacy_msg_id: str | None = None, 

251 force: bool = False, 

252 **send_kwargs: Any, # noqa:ANN401 

253 ) -> MessageOrPresenceTypeVar: 

254 if stanza.get_from().resource: 

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

256 else: 

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

258 self.__add_nick_element(stanza) 

259 if ( 

260 not self.is_user 

261 and isinstance(stanza, Presence) 

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

263 and not force 

264 ): 

265 return stanza 

266 if full_jid: 

267 stanza["to"] = full_jid 

268 if self.is_user: 

269 assert stanza.stream is not None 

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

271 else: 

272 stanza.send() 

273 else: 

274 backfill = bool(self._room.get_lock("fill history")) 

275 if isinstance(stanza, Message): 

276 self._room.archive.add(stanza, self, backfill, legacy_msg_id) 

277 if backfill: 

278 return stanza 

279 for user_full_jid in self._room.user_full_jids(): 

280 stanza = copy(stanza) 

281 stanza["to"] = user_full_jid 

282 stanza.send() 

283 return stanza 

284 

285 def mucadmin_item(self) -> MUCAdminItem: 

286 item = MUCAdminItem() 

287 item["nick"] = self.nickname 

288 item["affiliation"] = self.affiliation 

289 item["role"] = self.role 

290 if not self._room.is_anonymous: 

291 if self.is_user: 

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

293 elif self.contact: 

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

295 else: 

296 warnings.warn( 

297 ( 

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

299 f" {self}" 

300 ), 

301 ) 

302 return item 

303 

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

305 if self.is_system: 

306 return 

307 if (nick := self.nickname) != self.jid.resource: 

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

309 n["nick"] = nick 

310 stanza.append(n) 

311 

312 def send_initial_presence( 

313 self, 

314 full_jid: JID, 

315 nick_change: bool = False, 

316 presence_id: str | None = None, 

317 mav_until: str | None = None, 

318 ) -> None: 

319 """ 

320 Called when the user joins a Room, as a mechanism 

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

322 

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

324 

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

326 :param nick_change: Used when the user joins and the Room renames them (code 210) 

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

328 """ 

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

330 codes = set() 

331 if nick_change: 

332 codes.add(210) 

333 

334 if self.is_user: 

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

336 # a crucial part of the Room join sequence for XMPP clients. 

337 kwargs = {} 

338 else: 

339 if self.contact: 

340 self.contact._stored.ensure_loaded("presence", self.xmpp.store.session) 

341 kwargs = { 

342 "last_seen": self.contact._stored.last_seen, 

343 "pstatus": self.contact._stored.pstatus, 

344 "pshow": self.contact._stored.pshow, 

345 } 

346 else: 

347 kwargs = {} 

348 p = self._make_presence( 

349 status_codes=codes, 

350 user_full_jid=full_jid, 

351 **kwargs, # type:ignore 

352 ) 

353 if presence_id: 

354 p["id"] = presence_id 

355 if self.is_user and mav_until is not None: 

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

357 self._send(p, full_jid) 

358 

359 def leave(self) -> None: 

360 """ 

361 Call this when the participant leaves the room 

362 """ 

363 self._room.remove_participant(self) 

364 

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

366 """ 

367 Call this when the participant is kicked from the room 

368 """ 

369 self._room.remove_participant(self, kick=True, reason=reason) 

370 

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

372 """ 

373 Call this when the participant is banned from the room 

374 """ 

375 self._room.remove_participant(self, ban=True, reason=reason) 

376 

377 async def get_disco_info( 

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

379 ) -> DiscoInfo: 

380 if self.contact is not None: 

381 return await self.contact.get_disco_info() 

382 return await super().get_disco_info() 

383 

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

385 for i in self._legacy_to_xmpp(legacy_msg_id): 

386 m = self._room.get_system_participant()._make_message() 

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

388 if self.is_system: 

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

390 else: 

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

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

393 if reason: 

394 m["retract"]["reason"] = reason 

395 self._send(m) 

396 

397 def set_room_subject( 

398 self, 

399 subject: str, 

400 full_jid: JID | None = None, 

401 when: datetime | None = None, 

402 update_muc: bool = True, 

403 ) -> None: 

404 if update_muc: 

405 self._room.subject = subject 

406 self._room.subject_setter = self.nickname 

407 self._room.subject_date = when 

408 

409 msg = self._make_message() 

410 if when is not None: 

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

412 msg["delay"]["from"] = self._room.jid 

413 if subject: 

414 msg["subject"] = subject 

415 else: 

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

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

418 self._send(msg, full_jid) 

419 

420 def set_thread_subject( 

421 self, 

422 thread: str, 

423 subject: str | None, 

424 when: datetime | None = None, 

425 ) -> None: 

426 msg = self._make_message() 

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

428 if when is not None: 

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

430 msg["delay"]["from"] = self._room.jid 

431 if subject: 

432 msg["subject"] = subject 

433 else: 

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

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

436 self._send(msg) 

437 

438 async def on_set_affiliation( 

439 self, 

440 affiliation: MucAffiliation, 

441 reason: str | None, 

442 nickname: str | None, 

443 ) -> None: 

444 """ 

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

446 for this group. 

447 

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

449 

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

451 :param affiliation: The new affiliation 

452 :param reason: A reason for this affiliation change 

453 :param nickname: 

454 """ 

455 raise NotImplementedError 

456 

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

458 """ 

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

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

461 

462 :param contact: Contact to be kicked 

463 :param reason: A reason for this kick 

464 """ 

465 raise NotImplementedError 

466 

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

468 """ 

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

470 to a legacy Room via :xep:`0249`. 

471 

472 The default implementation calls :meth:`LegacyRoom.on_set_affiliation` 

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

474 behaviour. 

475 

476 :param muc: The group 

477 :param reason: Optionally, a reason 

478 """ 

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

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

481 

482 def displayed(self, legacy_msg_id: str, **kwargs: object) -> None: 

483 if self.xmpp.MARK_ALL_MESSAGES: 

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

485 if self.xmpp.store.mam.is_displayed_by_user( 

486 orm, self._room.jid.local, str(legacy_msg_id) 

487 ): 

488 self._session.log.debug( 

489 "Ignoring carbon marker for message already displayed by user." 

490 ) 

491 return 

492 else: 

493 self._room.pop_unread_xmpp_ids_up_to( 

494 self._legacy_to_xmpp(legacy_msg_id)[-1] 

495 ) 

496 

497 super().displayed(legacy_msg_id, **kwargs) 

498 

499 if not self.is_user: 

500 return 

501 self._session.create_task( 

502 self.__send_mds(legacy_msg_id), 

503 name=f"send MDS {legacy_msg_id!r} for {self}", 

504 ) 

505 

506 async def __send_mds(self, legacy_msg_id: str) -> None: 

507 # Send a MDS displayed marker on behalf of the user for a group chat 

508 # This is not implemented for 1:1 chat because it would rely on 

509 # storing the XMPP-server injected stanza-id, which we don't track 

510 # ATM. 

511 # In practice, MDS should mostly be useful for public group chats, 

512 # so it should not be an issue. 

513 # We'll see if we need to implement that later 

514 PUBLISH_OPTIONS = Form() 

515 PUBLISH_OPTIONS["type"] = "submit" 

516 PUBLISH_OPTIONS.add_field( 

517 "FORM_TYPE", 

518 "hidden", 

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

520 ) 

521 PUBLISH_OPTIONS.add_field("pubsub#persist_items", value="true") 

522 PUBLISH_OPTIONS.add_field("pubsub#max_items", value="max") 

523 PUBLISH_OPTIONS.add_field("pubsub#send_last_published_item", value="never") 

524 PUBLISH_OPTIONS.add_field("pubsub#access_model", value="whitelist") 

525 xmpp_msg_id = self._legacy_to_xmpp(legacy_msg_id)[-1] 

526 muc_jid = self._room.jid 

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

528 iq["pubsub"]["publish"]["node"] = self.xmpp.plugin["xep_0490"].stanza.NS 

529 iq["pubsub"]["publish"]["item"]["id"] = muc_jid 

530 displayed = self.xmpp.plugin["xep_0490"].stanza.Displayed() 

531 displayed["stanza_id"]["id"] = xmpp_msg_id 

532 displayed["stanza_id"]["by"] = muc_jid 

533 iq["pubsub"]["publish"]["item"]["payload"] = displayed 

534 iq["pubsub"]["publish_options"] = PUBLISH_OPTIONS 

535 try: 

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

537 except Exception as e: 

538 self._session.log.debug("Could not MDS mark", exc_info=e) 

539 

540 

541log = logging.getLogger(__name__)