Coverage for slidge/contact/contact.py: 89%

272 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-29 05:05 +0000

1import datetime 

2import logging 

3import warnings 

4from collections.abc import Iterable, Iterator, Sequence 

5from datetime import date 

6from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self 

7 

8import sqlalchemy as sa 

9from slixmpp import JID, Message, Presence 

10from slixmpp.exceptions import IqError, IqTimeout 

11from slixmpp.plugins.xep_0292.stanza import VCard4 

12from slixmpp.types import MessageTypes 

13 

14from slidge.db.avatar import CachedAvatar 

15 

16from ..core.mixins import AvatarMixin, FullCarbonMixin 

17from ..core.mixins.disco import ContactAccountDiscoMixin 

18from ..core.mixins.recipient import RecipientMixin 

19from ..db.models import Contact, ContactSent 

20from ..util.types import ( 

21 AnySession, 

22 ClientType, 

23 ContactMessage, 

24 ContactSticker, 

25 HoleBound, 

26 MessageOrPresenceTypeVar, 

27) 

28 

29if TYPE_CHECKING: 

30 from ..command.base import ContactCommand 

31 from ..group.participant import LegacyParticipant 

32 

33 

34class LegacyContact( 

35 AvatarMixin, 

36 ContactAccountDiscoMixin, 

37 FullCarbonMixin, 

38 RecipientMixin, 

39): 

40 """ 

41 This class centralizes actions in relation to a specific legacy contact. 

42 

43 You shouldn't create instances of contacts manually, but rather rely on 

44 :meth:`.LegacyRoster.by_legacy_id` to ensure that contact instances are 

45 singletons. The :class:`.LegacyRoster` instance of a session is accessible 

46 through the :attr:`.BaseSession.contacts` attribute. 

47 

48 Typically, your plugin should have methods hook to the legacy events and 

49 call appropriate methods here to transmit the "legacy action" to the xmpp 

50 user. This should look like this: 

51 

52 .. code-block:python 

53 

54 class Session(BaseSession): 

55 ... 

56 

57 async def on_cool_chat_network_new_text_message(self, legacy_msg_event): 

58 contact = self.contacts.by_legacy_id(legacy_msg_event.from) 

59 contact.send_text(legacy_msg_event.text) 

60 

61 async def on_cool_chat_network_new_typing_event(self, legacy_typing_event): 

62 contact = self.contacts.by_legacy_id(legacy_msg_event.from) 

63 contact.composing() 

64 ... 

65 

66 Use ``carbon=True`` as a keyword arg for methods to represent an action FROM 

67 the user TO the contact, typically when the user uses an official client to 

68 do an action such as sending a message or marking as message as read. 

69 This will use :xep:`0363` to impersonate the XMPP user in order. 

70 """ 

71 

72 RESOURCE: str = "slidge" 

73 """ 

74 A full JID, including a resource part is required for chat states (and maybe other stuff) 

75 to work properly. This is the name of the resource the contacts will use. 

76 """ 

77 PROPAGATE_PRESENCE_TO_GROUPS = True 

78 

79 mtype: MessageTypes = "chat" 

80 _can_send_carbon = True 

81 is_participant: Literal[False] = False 

82 is_group: Literal[False] = False 

83 

84 _ONLY_SEND_PRESENCE_CHANGES = True 

85 

86 STRIP_SHORT_DELAY = True 

87 _NON_FRIEND_PRESENCES_FILTER: ClassVar[set[str]] = {"subscribe", "unsubscribed"} 

88 

89 INVITATION_RECIPIENT = True 

90 

91 commands: ClassVar[dict[str, "type[ContactCommand[LegacyContact]]"]] = {} 

92 commands_chat: ClassVar[dict[str, "type[ContactCommand[LegacyContact]]"]] = {} 

93 

94 stored: Contact 

95 model: Contact 

96 

97 def __init__(self, session: AnySession, stored: Contact) -> None: 

98 self.session = session 

99 self.xmpp = session.xmpp 

100 self.stored = stored 

101 self._set_logger() 

102 super().__init__() 

103 

104 def _recipient_pk(self) -> int: 

105 return self.stored.id 

106 

107 async def on_message(self, message: ContactMessage) -> str | None: 

108 """ 

109 Triggered when the user sends a message to this :term:`Contact`. 

110 

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

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

113 """ 

114 raise NotImplementedError 

115 

116 async def on_sticker(self, sticker: ContactSticker) -> str | None: 

117 """ 

118 Triggered when the user sends a sticker to this :term:`Contact`. 

119 

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

121 

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

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

124 """ 

125 raise NotImplementedError 

126 

127 @property 

128 def jid(self) -> JID: 

129 jid = JID(self.stored.jid) 

130 jid.resource = self.RESOURCE 

131 return jid 

132 

133 @jid.setter 

134 def jid(self, _jid: JID) -> None: 

135 raise RuntimeError 

136 

137 @property 

138 def legacy_id(self) -> str: 

139 return self.stored.legacy_id 

140 

141 async def get_vcard(self, fetch: bool = True) -> VCard4: 

142 if fetch and not self.stored.vcard_fetched: 

143 await self.fetch_vcard() 

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

145 orm.add(self.stored) 

146 return self.stored.vcard(self.xmpp.boundjid.bare) 

147 

148 @property 

149 def is_friend(self) -> bool: 

150 return self.stored.is_friend 

151 

152 @is_friend.setter 

153 def is_friend(self, value: bool) -> None: 

154 if value == self.is_friend: 

155 return 

156 self.update_stored_attribute(is_friend=value) 

157 

158 @property 

159 def added_to_roster(self) -> bool: 

160 return self.stored.added_to_roster 

161 

162 @added_to_roster.setter 

163 def added_to_roster(self, value: bool) -> None: 

164 if value == self.added_to_roster: 

165 return 

166 self.update_stored_attribute(added_to_roster=value) 

167 

168 @property 

169 def participants(self) -> Iterator["LegacyParticipant[Self]"]: 

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

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

172 participants = self.stored.participants 

173 for p in participants: 

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

175 p = orm.merge(p) 

176 muc = self.session.bookmarks.from_store(p.room) 

177 part = muc.participant_from_store(p, contact=self) 

178 yield part 

179 

180 @property # type:ignore 

181 def DISCO_TYPE(self) -> ClientType: 

182 return self.client_type 

183 

184 @DISCO_TYPE.setter 

185 def DISCO_TYPE(self, value: ClientType) -> None: 

186 self.client_type = value 

187 

188 @property 

189 def client_type(self) -> ClientType: 

190 """ 

191 The client type of this contact, cf https://xmpp.org/registrar/disco-categories.html#client 

192 

193 Default is "pc". 

194 """ 

195 return self.stored.client_type 

196 

197 @client_type.setter 

198 def client_type(self, value: ClientType) -> None: 

199 if self.stored.client_type == value: 

200 return 

201 self.update_stored_attribute(client_type=value) 

202 

203 def _set_logger(self) -> None: 

204 self.log = logging.getLogger(f"{self.user_jid.bare}:contact:{self}") 

205 

206 def __repr__(self) -> str: 

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

208 

209 def __get_subscription_string(self) -> str: 

210 if self.is_friend: 

211 return "both" 

212 return "none" 

213 

214 def __propagate_to_participants(self, stanza: Presence) -> None: 

215 if not self.PROPAGATE_PRESENCE_TO_GROUPS: 

216 return 

217 

218 ptype = stanza["type"] 

219 if ptype in ("available", "chat"): 

220 func_name = "online" 

221 elif ptype in ("xa", "unavailable"): 

222 # we map unavailable to extended_away, because offline is 

223 # "participant leaves the MUC" 

224 # TODO: improve this with a clear distinction between participant 

225 # and member list 

226 func_name = "extended_away" 

227 elif ptype == "busy": 

228 func_name = "busy" 

229 elif ptype == "away": 

230 func_name = "away" 

231 else: 

232 return 

233 

234 last_seen: datetime.datetime | None = ( 

235 stanza["idle"]["since"] if "idle" in stanza else None 

236 ) 

237 

238 kw = {"status": stanza["status"], "last_seen": last_seen} 

239 

240 for part in self.participants: 

241 func = getattr(part, func_name) 

242 func(**kw) 

243 

244 def _send( 

245 self, 

246 stanza: MessageOrPresenceTypeVar, 

247 carbon: bool = False, 

248 nick: bool = False, 

249 **send_kwargs: Any, # noqa:ANN401 

250 ) -> MessageOrPresenceTypeVar: 

251 if carbon and isinstance(stanza, Message): 

252 stanza["to"] = self.jid.bare 

253 stanza["from"] = self.user_jid 

254 self._privileged_send(stanza) 

255 return stanza 

256 

257 if isinstance(stanza, Presence): 

258 if not self._updating_info: 

259 self.__propagate_to_participants(stanza) 

260 if ( 

261 not self.is_friend 

262 and stanza["type"] not in self._NON_FRIEND_PRESENCES_FILTER 

263 ): 

264 return stanza 

265 if self.name and (nick or not self.is_friend): 

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

267 n["nick"] = self.name 

268 stanza.append(n) 

269 if ( 

270 not self._updating_info 

271 and self.xmpp.MARK_ALL_MESSAGES 

272 and is_markable(stanza) 

273 ): 

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

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

276 exists = ( 

277 orm.query(ContactSent) 

278 .filter_by(contact_id=self.stored.id, msg_id=stanza["id"]) 

279 .first() 

280 ) 

281 if exists: 

282 self.log.warning( 

283 "Contact has already sent message %s", stanza["id"] 

284 ) 

285 else: 

286 new = ContactSent(contact=self.stored, msg_id=stanza["id"]) 

287 orm.add(new) 

288 self.stored.sent_order.append(new) 

289 orm.commit() 

290 stanza["to"] = self.user_jid 

291 stanza.send() 

292 return stanza 

293 

294 def _store_last_sent_msg( 

295 self, legacy_id: str, when: datetime.datetime | None 

296 ) -> None: 

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

298 orm.execute( 

299 sa.update(Contact) 

300 .where(Contact.id == self._recipient_pk()) 

301 .values( 

302 last_sent_msg_legacy_id=legacy_id, 

303 last_sent_msg_date=when or datetime.datetime.now(tz=datetime.UTC), 

304 ) 

305 ) 

306 orm.commit() 

307 

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

309 """ 

310 Return XMPP msg ids sent by this contact up to a given XMPP msg id. 

311 

312 Legacy modules have no reason to use this, but it is used by slidge core 

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

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

315 

316 This has side effects, if the horizon XMPP id is found, messages up to 

317 this horizon are cleared, to avoid sending the same read mark twice. 

318 

319 :param horizon_xmpp_id: The latest message 

320 :return: A list of XMPP ids up to horizon_xmpp_id, included 

321 """ 

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

323 assert self.stored.id is not None 

324 ids = self.xmpp.store.contacts.pop_sent_up_to( 

325 orm, self.stored.id, horizon_xmpp_id 

326 ) 

327 orm.commit() 

328 return ids 

329 

330 @property 

331 def name(self) -> str: 

332 """ 

333 Friendly name of the contact, as it should appear in the user's roster 

334 """ 

335 return self.stored.nick or "" 

336 

337 @name.setter 

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

339 if self.stored.nick == n: 

340 return 

341 self.update_stored_attribute(nick=n) 

342 self._set_logger() 

343 if self.is_friend and self.added_to_roster: 

344 self.xmpp.pubsub.broadcast_nick( 

345 user_jid=self.user_jid, jid=self.jid.bare, nick=n 

346 ) 

347 for p in self.participants: 

348 p.nickname = n or str(self.legacy_id) 

349 

350 def _post_avatar_update(self, cached_avatar: CachedAvatar | None) -> None: 

351 if self.is_friend and self.added_to_roster: 

352 self.session.create_task( 

353 self.session.xmpp.pubsub.broadcast_avatar( 

354 self.jid.bare, self.session.user_jid, cached_avatar 

355 ), 

356 name=f"Post avatar update of {self}", 

357 ) 

358 for p in self.participants: 

359 self.log.debug("Propagating new avatar to %s", p.muc) 

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

361 

362 def set_vcard( 

363 self, 

364 /, 

365 full_name: str | None = None, 

366 given: str | None = None, 

367 surname: str | None = None, 

368 birthday: date | None = None, 

369 phone: str | None = None, 

370 phones: Iterable[str] = (), 

371 note: str | None = None, 

372 url: str | None = None, 

373 email: str | None = None, 

374 country: str | None = None, 

375 locality: str | None = None, 

376 pronouns: str | None = None, 

377 ) -> None: 

378 """ 

379 Update xep:`0292` data for this contact. 

380 

381 Use this for additional metadata about this contact to be available to XMPP 

382 clients. The "note" argument is a text of arbitrary size and can be useful when 

383 no other field is a good fit. 

384 """ 

385 if given: 

386 warnings.warn( 

387 "Contact.set_vcard() 'given' arg is deprecated", 

388 FutureWarning, 

389 stacklevel=2, 

390 ) 

391 if surname: 

392 warnings.warn( 

393 "Contact.set_vcard() 'surname' arg is deprecated", 

394 FutureWarning, 

395 stacklevel=2, 

396 ) 

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

398 orm.add(self.stored) 

399 self.stored.full_name = full_name 

400 self.stored.birthday = birthday 

401 self.stored.note = note 

402 self.stored.url = url 

403 self.stored.email = email 

404 self.stored.phones = [x for x in [phone, *phones] if x] 

405 self.stored.locality = locality 

406 self.stored.pronouns = pronouns 

407 self.stored.country = country 

408 self.stored.vcard_fetched = True 

409 orm.commit() 

410 

411 self.session.create_task( 

412 self.xmpp.pubsub.broadcast_vcard_event( 

413 self.jid, self.user_jid, self.stored.vcard(self.xmpp.boundjid.bare) 

414 ), 

415 name=f"Broadcast vcard of {self}", 

416 ) 

417 

418 def get_roster_item(self) -> dict[str, dict[str, str | Sequence[str]]]: 

419 item = { 

420 "subscription": self.__get_subscription_string(), 

421 "groups": [self.xmpp.ROSTER_GROUP], 

422 } 

423 if (n := self.name) is not None: 

424 item["name"] = n 

425 return {self.jid.bare: item} 

426 

427 async def add_to_roster(self, force: bool = False) -> None: 

428 """ 

429 Add this contact to the user roster using :xep:`0356` 

430 

431 :param force: add even if the contact was already added successfully 

432 """ 

433 if self.added_to_roster and not force: 

434 return 

435 if not self.session.user.preferences.get("roster_push", True): 

436 log.debug("Roster push request by plugin ignored (--no-roster-push)") 

437 return 

438 try: 

439 await self.xmpp.plugin["xep_0356"].set_roster( 

440 jid=self.user_jid, roster_items=self.get_roster_item() 

441 ) 

442 except PermissionError: 

443 warnings.warn( 

444 f"Slidge does not have the privilege (XEP-0356) to manage the roster of {self.user_jid}. " 

445 "If this is a local user, consider configuring your XMPP server for that." 

446 ) 

447 self.send_friend_request( 

448 f"I'm already your friend on {self.xmpp.COMPONENT_TYPE}, but " 

449 "slidge is not allowed to manage your roster." 

450 ) 

451 return 

452 except (IqError, IqTimeout) as e: 

453 self.log.warning("Could not add to roster", exc_info=e) 

454 else: 

455 # we only broadcast pubsub events for contacts added to the roster 

456 # so if something was set before, we need to push it now 

457 self.added_to_roster = True 

458 self.send_last_presence(force=True) 

459 

460 async def __broadcast_pubsub_items(self) -> None: 

461 if not self.is_friend: 

462 return 

463 if not self.added_to_roster: 

464 return 

465 cached_avatar = self.get_cached_avatar() 

466 if cached_avatar is not None: 

467 await self.xmpp.pubsub.broadcast_avatar( 

468 self.jid.bare, self.session.user_jid, cached_avatar 

469 ) 

470 nick = self.name 

471 

472 if nick is not None: 

473 self.xmpp.pubsub.broadcast_nick( 

474 self.session.user_jid, 

475 self.jid.bare, 

476 nick, 

477 ) 

478 

479 def send_friend_request(self, text: str | None = None) -> None: 

480 presence = self._make_presence(ptype="subscribe", pstatus=text, bare=True) 

481 self._send(presence, nick=True) 

482 

483 async def accept_friend_request(self, text: str | None = None) -> None: 

484 """ 

485 Call this to signify that this Contact has accepted to be a friend 

486 of the user. 

487 

488 :param text: Optional message from the friend to the user 

489 """ 

490 self.is_friend = True 

491 self.added_to_roster = True 

492 self.log.debug("Accepting friend request") 

493 presence = self._make_presence(ptype="subscribed", pstatus=text, bare=True) 

494 self._send(presence, nick=True) 

495 self.send_last_presence() 

496 await self.__broadcast_pubsub_items() 

497 self.log.debug("Accepted friend request") 

498 

499 def reject_friend_request(self, text: str | None = None) -> None: 

500 """ 

501 Call this to signify that this Contact has refused to be a contact 

502 of the user (or that they don't want to be friends anymore) 

503 

504 :param text: Optional message from the non-friend to the user 

505 """ 

506 presence = self._make_presence(ptype="unsubscribed", pstatus=text, bare=True) 

507 self.offline() 

508 self._send(presence, nick=True) 

509 self.is_friend = False 

510 

511 async def on_friend_request(self, text: str = "") -> None: 

512 """ 

513 Called when receiving a "subscribe" presence, ie, "I would like to add 

514 you to my contacts/friends", from the user to this contact. 

515 

516 In XMPP terms: "I would like to receive your presence updates" 

517 

518 This is only called if self.is_friend = False. If self.is_friend = True, 

519 slidge will automatically "accept the friend request", ie, reply with 

520 a "subscribed" presence. 

521 

522 When called, a 'friend request event' should be sent to the legacy 

523 service, and when the contact responds, you should either call 

524 self.accept_subscription() or self.reject_subscription() 

525 """ 

526 

527 async def on_friend_delete(self, text: str = "") -> None: 

528 """ 

529 Called when receiving an "unsubscribed" presence, ie, "I would like to 

530 remove you to my contacts/friends" or "I refuse your friend request" 

531 from the user to this contact. 

532 

533 In XMPP terms: "You won't receive my presence updates anymore (or you 

534 never have)". 

535 """ 

536 

537 async def on_friend_accept(self) -> None: 

538 """ 

539 Called when receiving a "subscribed" presence, ie, "I accept to be 

540 your/confirm that you are my friend" from the user to this contact. 

541 

542 In XMPP terms: "You will receive my presence updates". 

543 """ 

544 

545 def unsubscribe(self) -> None: 

546 """ 

547 (internal use by slidge) 

548 

549 Send an "unsubscribe", "unsubscribed", "unavailable" presence sequence 

550 from this contact to the user, ie, "this contact has removed you from 

551 their 'friends'". 

552 """ 

553 for ptype in "unsubscribe", "unsubscribed", "unavailable": 

554 self.xmpp.send_presence(pfrom=self.jid, pto=self.user_jid.bare, ptype=ptype) 

555 

556 async def update_info(self) -> None: 

557 """ 

558 Fetch information about this contact from the legacy network 

559 

560 This is awaited on Contact instantiation, and should be overridden to 

561 update the nickname, avatar, vcard [...] of this contact, by making 

562 "legacy API calls". 

563 

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

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

566 is no change, you should not call 

567 :py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar` or attempt 

568 to modify the ``.avatar`` property. 

569 

570 :raises XMPPError: MUST be raised when the legacy contact does not exist. 

571 """ 

572 

573 async def fetch_vcard(self) -> None: 

574 """ 

575 It the legacy network doesn't like that you fetch too many profiles on startup, 

576 it's also possible to fetch it here, which will be called when XMPP clients 

577 of the user request the vcard, if it hasn't been fetched before 

578 :return: 

579 """ 

580 

581 def _make_presence( 

582 self, 

583 *, 

584 last_seen: datetime.datetime | None = None, 

585 **presence_kwargs: Any, # noqa:ANN401 

586 ) -> Presence: 

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

588 caps = self.xmpp.plugin["xep_0115"] 

589 

590 if p.get_from().resource and (ver := self.xmpp.get_caps_ver(self.client_type)): 

591 p["caps"]["node"] = caps.caps_node 

592 p["caps"]["hash"] = caps.hash 

593 p["caps"]["ver"] = ver 

594 return p 

595 

596 async def backfill(self, after: HoleBound | None) -> None: 

597 """ 

598 This method can be overridden to implement history fetching for this 

599 contact. 

600 

601 Since the message archive (:xep:`0313`) is managed by the XMPP server 

602 of the user, there are several caveats. 

603 

604 - We cannot prevent the XMPP server from injecting a :xep:`0203` 

605 timestamp at the time it receives the messages. Most XMPP clients 

606 will use that timestamp instead of the one we inject and thus message 

607 ordering may be messed up under certain circumstances. 

608 - We cannot know if a message is already in this archive, so legacy 

609 modules should ensure that this does not send message that have 

610 already sent before or there will be duplicates. 

611 - Related to the previous point, if a legacy network APIs returns a 

612 message while iterating over a "fetch history" call and also pass 

613 it as a live message, you may end up with duplicates. 

614 

615 For these reasons, we recommend sending only recent messages, mostly 

616 messages that could have been missed when slidge was down. 

617 

618 NB: if the legacy client receives "message while it was down" as live 

619 messages on startup, this is pretty much useless. 

620 

621 :param after: Last message to or from this contact that slidge saw 

622 passing through. If ``None``, it means slidge never saw any message 

623 from this contact. 

624 """ 

625 raise NotImplementedError 

626 

627 

628def is_markable(stanza: Message | Presence) -> bool: 

629 if isinstance(stanza, Presence): 

630 return False 

631 return bool(stanza["body"]) 

632 

633 

634log = logging.getLogger(__name__)