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

285 statements  

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

7from xml.etree import ElementTree as ET 

8 

9import sqlalchemy as sa 

10from slixmpp import JID, Message, Presence 

11from slixmpp.exceptions import IqError, IqTimeout 

12from slixmpp.plugins.xep_0292.stanza import VCard4 

13from slixmpp.types import MessageTypes 

14 

15from slidge.db.avatar import CachedAvatar 

16 

17from ..core.mixins import AvatarMixin, FullCarbonMixin 

18from ..core.mixins.disco import ContactAccountDiscoMixin 

19from ..core.mixins.recipient import RecipientMixin 

20from ..db.models import Contact, ContactSent 

21from ..util.types import ( 

22 AnySession, 

23 ClientType, 

24 ContactMessage, 

25 ContactSticker, 

26 HoleBound, 

27 MessageOrPresenceTypeVar, 

28) 

29 

30if TYPE_CHECKING: 

31 from ..command.base import ContactCommand 

32 from ..group.participant import LegacyParticipant 

33 

34 

35class LegacyContact( 

36 AvatarMixin, 

37 ContactAccountDiscoMixin, 

38 FullCarbonMixin, 

39 RecipientMixin, 

40): 

41 """ 

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

43 

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

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

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

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

48 

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

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

51 user. This should look like this: 

52 

53 .. code-block:python 

54 

55 class Session(BaseSession): 

56 ... 

57 

58 async def on_cool_chat_network_new_text_message(self, legacy_msg_event): 

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

60 contact.send_text(legacy_msg_event.text) 

61 

62 async def on_cool_chat_network_new_typing_event(self, legacy_typing_event): 

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

64 contact.composing() 

65 ... 

66 

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

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

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

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

71 """ 

72 

73 RESOURCE: str = "slidge" 

74 """ 

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

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

77 """ 

78 PROPAGATE_PRESENCE_TO_GROUPS = True 

79 

80 mtype: MessageTypes = "chat" 

81 _can_send_carbon = True 

82 is_participant: Literal[False] = False 

83 is_group: Literal[False] = False 

84 

85 _ONLY_SEND_PRESENCE_CHANGES = True 

86 

87 STRIP_SHORT_DELAY = True 

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

89 

90 INVITATION_RECIPIENT = True 

91 

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

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

94 

95 stored: Contact 

96 model: Contact 

97 

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

99 self.session = session 

100 self.xmpp = session.xmpp 

101 self.stored = stored 

102 self._set_logger() 

103 super().__init__() 

104 

105 def _recipient_pk(self) -> int: 

106 return self.stored.id 

107 

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

109 """ 

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

111 

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

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

114 """ 

115 raise NotImplementedError 

116 

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

118 """ 

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

120 

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

122 

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

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

125 """ 

126 raise NotImplementedError 

127 

128 @property 

129 def jid(self) -> JID: 

130 jid = JID(self.stored.jid) 

131 jid.resource = self.RESOURCE 

132 return jid 

133 

134 @jid.setter 

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

136 raise RuntimeError 

137 

138 @property 

139 def legacy_id(self) -> str: 

140 return self.stored.legacy_id 

141 

142 async def get_vcard(self, fetch: bool = True) -> VCard4 | None: 

143 if fetch and not self.stored.vcard_fetched: 

144 await self.fetch_vcard() 

145 if self.stored.vcard is None: 

146 return None 

147 

148 return VCard4(xml=ET.fromstring(self.stored.vcard)) 

149 

150 @property 

151 def is_friend(self) -> bool: 

152 return self.stored.is_friend 

153 

154 @is_friend.setter 

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

156 if value == self.is_friend: 

157 return 

158 self.update_stored_attribute(is_friend=value) 

159 

160 @property 

161 def added_to_roster(self) -> bool: 

162 return self.stored.added_to_roster 

163 

164 @added_to_roster.setter 

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

166 if value == self.added_to_roster: 

167 return 

168 self.update_stored_attribute(added_to_roster=value) 

169 

170 @property 

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

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

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

174 participants = self.stored.participants 

175 for p in participants: 

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

177 p = orm.merge(p) 

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

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

180 yield part 

181 

182 @property # type:ignore 

183 def DISCO_TYPE(self) -> ClientType: 

184 return self.client_type 

185 

186 @DISCO_TYPE.setter 

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

188 self.client_type = value 

189 

190 @property 

191 def client_type(self) -> ClientType: 

192 """ 

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

194 

195 Default is "pc". 

196 """ 

197 return self.stored.client_type 

198 

199 @client_type.setter 

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

201 if self.stored.client_type == value: 

202 return 

203 self.update_stored_attribute(client_type=value) 

204 

205 def _set_logger(self) -> None: 

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

207 

208 def __repr__(self) -> str: 

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

210 

211 def __get_subscription_string(self) -> str: 

212 if self.is_friend: 

213 return "both" 

214 return "none" 

215 

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

217 if not self.PROPAGATE_PRESENCE_TO_GROUPS: 

218 return 

219 

220 ptype = stanza["type"] 

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

222 func_name = "online" 

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

224 # we map unavailable to extended_away, because offline is 

225 # "participant leaves the MUC" 

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

227 # and member list 

228 func_name = "extended_away" 

229 elif ptype == "busy": 

230 func_name = "busy" 

231 elif ptype == "away": 

232 func_name = "away" 

233 else: 

234 return 

235 

236 last_seen: datetime.datetime | None = ( 

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

238 ) 

239 

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

241 

242 for part in self.participants: 

243 func = getattr(part, func_name) 

244 func(**kw) 

245 

246 def _send( 

247 self, 

248 stanza: MessageOrPresenceTypeVar, 

249 carbon: bool = False, 

250 nick: bool = False, 

251 **send_kwargs: Any, # noqa:ANN401 

252 ) -> MessageOrPresenceTypeVar: 

253 if carbon and isinstance(stanza, Message): 

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

255 stanza["from"] = self.user_jid 

256 self._privileged_send(stanza) 

257 return stanza 

258 

259 if isinstance(stanza, Presence): 

260 if not self._updating_info: 

261 self.__propagate_to_participants(stanza) 

262 if ( 

263 not self.is_friend 

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

265 ): 

266 return stanza 

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

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

269 n["nick"] = self.name 

270 stanza.append(n) 

271 if ( 

272 not self._updating_info 

273 and self.xmpp.MARK_ALL_MESSAGES 

274 and is_markable(stanza) 

275 ): 

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

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

278 exists = ( 

279 orm.query(ContactSent) 

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

281 .first() 

282 ) 

283 if exists: 

284 self.log.warning( 

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

286 ) 

287 else: 

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

289 orm.add(new) 

290 self.stored.sent_order.append(new) 

291 orm.commit() 

292 stanza["to"] = self.user_jid 

293 stanza.send() 

294 return stanza 

295 

296 def _store_last_sent_msg( 

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

298 ) -> None: 

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

300 orm.execute( 

301 sa.update(Contact) 

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

303 .values( 

304 last_sent_msg_legacy_id=legacy_id, 

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

306 ) 

307 ) 

308 orm.commit() 

309 

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

311 """ 

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

313 

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

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

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

317 

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

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

320 

321 :param horizon_xmpp_id: The latest message 

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

323 """ 

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

325 assert self.stored.id is not None 

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

327 orm, self.stored.id, horizon_xmpp_id 

328 ) 

329 orm.commit() 

330 return ids 

331 

332 @property 

333 def name(self) -> str: 

334 """ 

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

336 """ 

337 return self.stored.nick or "" 

338 

339 @name.setter 

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

341 if self.stored.nick == n: 

342 return 

343 self.update_stored_attribute(nick=n) 

344 self._set_logger() 

345 if self.is_friend and self.added_to_roster: 

346 self.xmpp.pubsub.broadcast_nick( 

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

348 ) 

349 for p in self.participants: 

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

351 

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

353 if self.is_friend and self.added_to_roster: 

354 self.session.create_task( 

355 self.session.xmpp.pubsub.broadcast_avatar( 

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

357 ), 

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

359 ) 

360 for p in self.participants: 

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

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

363 

364 def set_vcard( 

365 self, 

366 /, 

367 full_name: str | None = None, 

368 given: str | None = None, 

369 surname: str | None = None, 

370 birthday: date | None = None, 

371 phone: str | None = None, 

372 phones: Iterable[str] = (), 

373 note: str | None = None, 

374 url: str | None = None, 

375 email: str | None = None, 

376 country: str | None = None, 

377 locality: str | None = None, 

378 pronouns: str | None = None, 

379 ) -> None: 

380 """ 

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

382 

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

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

385 no other field is a good fit. 

386 """ 

387 vcard = VCard4() 

388 vcard.add_impp(f"xmpp:{self.jid.bare}") 

389 

390 if n := self.name: 

391 vcard.add_nickname(n) 

392 if full_name: 

393 vcard["full_name"] = full_name 

394 elif n: 

395 vcard["full_name"] = n 

396 

397 if given: 

398 vcard["given"] = given 

399 if surname: 

400 vcard["surname"] = surname 

401 if birthday: 

402 vcard["birthday"] = birthday 

403 

404 if note: 

405 vcard.add_note(note) 

406 if url: 

407 vcard.add_url(url) 

408 if email: 

409 vcard.add_email(email) 

410 if phone: 

411 vcard.add_tel(phone) 

412 for p in phones: 

413 vcard.add_tel(p) 

414 if (country and locality) or country: 

415 vcard.add_address(country, locality) 

416 if pronouns: 

417 vcard["pronouns"]["text"] = pronouns 

418 

419 self.update_stored_attribute(vcard=str(vcard), vcard_fetched=True) 

420 self.session.create_task( 

421 self.xmpp.pubsub.broadcast_vcard_event(self.jid, self.user_jid, vcard), 

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

423 ) 

424 

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

426 item = { 

427 "subscription": self.__get_subscription_string(), 

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

429 } 

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

431 item["name"] = n 

432 return {self.jid.bare: item} 

433 

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

435 """ 

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

437 

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

439 """ 

440 if self.added_to_roster and not force: 

441 return 

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

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

444 return 

445 try: 

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

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

448 ) 

449 except PermissionError: 

450 warnings.warn( 

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

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

453 ) 

454 self.send_friend_request( 

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

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

457 ) 

458 return 

459 except (IqError, IqTimeout) as e: 

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

461 else: 

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

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

464 self.added_to_roster = True 

465 self.send_last_presence(force=True) 

466 

467 async def __broadcast_pubsub_items(self) -> None: 

468 if not self.is_friend: 

469 return 

470 if not self.added_to_roster: 

471 return 

472 cached_avatar = self.get_cached_avatar() 

473 if cached_avatar is not None: 

474 await self.xmpp.pubsub.broadcast_avatar( 

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

476 ) 

477 nick = self.name 

478 

479 if nick is not None: 

480 self.xmpp.pubsub.broadcast_nick( 

481 self.session.user_jid, 

482 self.jid.bare, 

483 nick, 

484 ) 

485 

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

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

488 self._send(presence, nick=True) 

489 

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

491 """ 

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

493 of the user. 

494 

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

496 """ 

497 self.is_friend = True 

498 self.added_to_roster = True 

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

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

501 self._send(presence, nick=True) 

502 self.send_last_presence() 

503 await self.__broadcast_pubsub_items() 

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

505 

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

507 """ 

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

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

510 

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

512 """ 

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

514 self.offline() 

515 self._send(presence, nick=True) 

516 self.is_friend = False 

517 

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

519 """ 

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

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

522 

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

524 

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

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

527 a "subscribed" presence. 

528 

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

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

531 self.accept_subscription() or self.reject_subscription() 

532 """ 

533 

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

535 """ 

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

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

538 from the user to this contact. 

539 

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

541 never have)". 

542 """ 

543 

544 async def on_friend_accept(self) -> None: 

545 """ 

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

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

548 

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

550 """ 

551 

552 def unsubscribe(self) -> None: 

553 """ 

554 (internal use by slidge) 

555 

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

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

558 their 'friends'". 

559 """ 

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

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

562 

563 async def update_info(self) -> None: 

564 """ 

565 Fetch information about this contact from the legacy network 

566 

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

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

569 "legacy API calls". 

570 

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

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

573 is no change, you should not call 

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

575 to modify the ``.avatar`` property. 

576 """ 

577 

578 async def fetch_vcard(self) -> None: 

579 """ 

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

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

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

583 :return: 

584 """ 

585 

586 def _make_presence( 

587 self, 

588 *, 

589 last_seen: datetime.datetime | None = None, 

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

591 user_full_jid: JID | None = None, 

592 **presence_kwargs: Any, # noqa:ANN401 

593 ) -> Presence: 

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

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

596 if p.get_from().resource and self.stored.caps_ver: 

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

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

599 p["caps"]["ver"] = self.stored.caps_ver 

600 return p 

601 

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

603 """ 

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

605 contact. 

606 

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

608 of the user, there are several caveats. 

609 

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

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

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

613 ordering may be messed up under certain circumstances. 

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

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

616 already sent before or there will be duplicates. 

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

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

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

620 

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

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

623 

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

625 messages on startup, this is pretty much useless. 

626 

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

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

629 from this contact. 

630 """ 

631 raise NotImplementedError 

632 

633 

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

635 if isinstance(stanza, Presence): 

636 return False 

637 return bool(stanza["body"]) 

638 

639 

640log = logging.getLogger(__name__)