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

279 statements  

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

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 MessageOrPresenceTypeVar, 

26) 

27 

28if TYPE_CHECKING: 

29 from ..command.base import ContactCommand 

30 from ..group.participant import LegacyParticipant 

31 

32 

33class LegacyContact( 

34 AvatarMixin, 

35 ContactAccountDiscoMixin, 

36 FullCarbonMixin, 

37 RecipientMixin, 

38): 

39 """ 

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

41 

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

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

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

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

46 

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

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

49 user. This should look like this: 

50 

51 .. code-block:python 

52 

53 class Session(BaseSession): 

54 ... 

55 

56 async def on_cool_chat_network_new_text_message(self, legacy_msg_event): 

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

58 contact.send_text(legacy_msg_event.text) 

59 

60 async def on_cool_chat_network_new_typing_event(self, legacy_typing_event): 

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

62 contact.composing() 

63 ... 

64 

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

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

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

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

69 """ 

70 

71 RESOURCE: str = "slidge" 

72 """ 

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

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

75 """ 

76 PROPAGATE_PRESENCE_TO_GROUPS = True 

77 

78 mtype: MessageTypes = "chat" 

79 _can_send_carbon = True 

80 is_participant: Literal[False] = False 

81 is_group: Literal[False] = False 

82 

83 _ONLY_SEND_PRESENCE_CHANGES = True 

84 

85 STRIP_SHORT_DELAY = True 

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

87 

88 INVITATION_RECIPIENT = True 

89 

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

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

92 

93 stored: Contact 

94 model: Contact 

95 

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

97 self.session = session 

98 self.xmpp = session.xmpp 

99 self.stored = stored 

100 self._set_logger() 

101 super().__init__() 

102 

103 def _recipient_pk(self) -> int: 

104 return self.stored.id 

105 

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

107 """ 

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

109 

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

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

112 """ 

113 raise NotImplementedError 

114 

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

116 """ 

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

118 

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

120 

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

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

123 """ 

124 raise NotImplementedError 

125 

126 @property 

127 def jid(self) -> JID: 

128 jid = JID(self.stored.jid) 

129 jid.resource = self.RESOURCE 

130 return jid 

131 

132 @jid.setter 

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

134 raise RuntimeError 

135 

136 @property 

137 def legacy_id(self) -> str: 

138 return self.stored.legacy_id 

139 

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

141 if fetch and not self.stored.vcard_fetched: 

142 await self.fetch_vcard() 

143 if self.stored.vcard is None: 

144 return None 

145 

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

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 pop_unread_xmpp_ids_up_to(self, horizon_xmpp_id: str) -> list[str]: 

295 """ 

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

297 

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

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

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

301 

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

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

304 

305 :param horizon_xmpp_id: The latest message 

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

307 """ 

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

309 assert self.stored.id is not None 

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

311 orm, self.stored.id, horizon_xmpp_id 

312 ) 

313 orm.commit() 

314 return ids 

315 

316 @property 

317 def name(self) -> str: 

318 """ 

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

320 """ 

321 return self.stored.nick or "" 

322 

323 @name.setter 

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

325 if self.stored.nick == n: 

326 return 

327 self.update_stored_attribute(nick=n) 

328 self._set_logger() 

329 if self.is_friend and self.added_to_roster: 

330 self.xmpp.pubsub.broadcast_nick( 

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

332 ) 

333 for p in self.participants: 

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

335 

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

337 if self.is_friend and self.added_to_roster: 

338 self.session.create_task( 

339 self.session.xmpp.pubsub.broadcast_avatar( 

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

341 ) 

342 ) 

343 for p in self.participants: 

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

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

346 

347 def set_vcard( 

348 self, 

349 /, 

350 full_name: str | None = None, 

351 given: str | None = None, 

352 surname: str | None = None, 

353 birthday: date | None = None, 

354 phone: str | None = None, 

355 phones: Iterable[str] = (), 

356 note: str | None = None, 

357 url: str | None = None, 

358 email: str | None = None, 

359 country: str | None = None, 

360 locality: str | None = None, 

361 pronouns: str | None = None, 

362 ) -> None: 

363 """ 

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

365 

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

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

368 no other field is a good fit. 

369 """ 

370 vcard = VCard4() 

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

372 

373 if n := self.name: 

374 vcard.add_nickname(n) 

375 if full_name: 

376 vcard["full_name"] = full_name 

377 elif n: 

378 vcard["full_name"] = n 

379 

380 if given: 

381 vcard["given"] = given 

382 if surname: 

383 vcard["surname"] = surname 

384 if birthday: 

385 vcard["birthday"] = birthday 

386 

387 if note: 

388 vcard.add_note(note) 

389 if url: 

390 vcard.add_url(url) 

391 if email: 

392 vcard.add_email(email) 

393 if phone: 

394 vcard.add_tel(phone) 

395 for p in phones: 

396 vcard.add_tel(p) 

397 if (country and locality) or country: 

398 vcard.add_address(country, locality) 

399 if pronouns: 

400 vcard["pronouns"]["text"] = pronouns 

401 

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

403 self.session.create_task( 

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

405 ) 

406 

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

408 item = { 

409 "subscription": self.__get_subscription_string(), 

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

411 } 

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

413 item["name"] = n 

414 return {self.jid.bare: item} 

415 

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

417 """ 

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

419 

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

421 """ 

422 if self.added_to_roster and not force: 

423 return 

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

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

426 return 

427 try: 

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

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

430 ) 

431 except PermissionError: 

432 warnings.warn( 

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

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

435 ) 

436 self.send_friend_request( 

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

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

439 ) 

440 return 

441 except (IqError, IqTimeout) as e: 

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

443 else: 

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

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

446 self.added_to_roster = True 

447 self.send_last_presence(force=True) 

448 

449 async def __broadcast_pubsub_items(self) -> None: 

450 if not self.is_friend: 

451 return 

452 if not self.added_to_roster: 

453 return 

454 cached_avatar = self.get_cached_avatar() 

455 if cached_avatar is not None: 

456 await self.xmpp.pubsub.broadcast_avatar( 

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

458 ) 

459 nick = self.name 

460 

461 if nick is not None: 

462 self.xmpp.pubsub.broadcast_nick( 

463 self.session.user_jid, 

464 self.jid.bare, 

465 nick, 

466 ) 

467 

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

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

470 self._send(presence, nick=True) 

471 

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

473 """ 

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

475 of the user. 

476 

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

478 """ 

479 self.is_friend = True 

480 self.added_to_roster = True 

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

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

483 self._send(presence, nick=True) 

484 self.send_last_presence() 

485 await self.__broadcast_pubsub_items() 

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

487 

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

489 """ 

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

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

492 

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

494 """ 

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

496 self.offline() 

497 self._send(presence, nick=True) 

498 self.is_friend = False 

499 

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

501 """ 

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

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

504 

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

506 

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

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

509 a "subscribed" presence. 

510 

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

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

513 self.accept_subscription() or self.reject_subscription() 

514 """ 

515 

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

517 """ 

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

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

520 from the user to this contact. 

521 

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

523 never have)". 

524 """ 

525 

526 async def on_friend_accept(self) -> None: 

527 """ 

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

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

530 

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

532 """ 

533 

534 def unsubscribe(self) -> None: 

535 """ 

536 (internal use by slidge) 

537 

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

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

540 their 'friends'". 

541 """ 

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

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

544 

545 async def update_info(self) -> None: 

546 """ 

547 Fetch information about this contact from the legacy network 

548 

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

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

551 "legacy API calls". 

552 

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

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

555 is no change, you should not call 

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

557 to modify the ``.avatar`` property. 

558 """ 

559 

560 async def fetch_vcard(self) -> None: 

561 """ 

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

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

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

565 :return: 

566 """ 

567 

568 def _make_presence( 

569 self, 

570 *, 

571 last_seen: datetime.datetime | None = None, 

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

573 user_full_jid: JID | None = None, 

574 **presence_kwargs: Any, # noqa:ANN401 

575 ) -> Presence: 

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

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

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

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

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

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

582 return p 

583 

584 

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

586 if isinstance(stanza, Presence): 

587 return False 

588 return bool(stanza["body"]) 

589 

590 

591log = logging.getLogger(__name__)