Coverage for slidge/contact/contact.py: 90%
281 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-10 12:26 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-10 12:26 +0000
1from __future__ import annotations
3import datetime
4import logging
5from collections.abc import Iterator
6from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self
8import sqlalchemy as sa
9from slixmpp import JID, Message, Presence
10from slixmpp.plugins.xep_0292.stanza import VCard4
11from slixmpp.types import MessageTypes, PresenceShows
12from typing_extensions import TypedDict
14from ..core.mixins import FullCarbonMixin, ImageMixin
15from ..core.mixins.disco import ContactAccountDiscoMixin
16from ..core.mixins.recipient import RecipientMixin
17from ..db.avatar import CachedAvatar
18from ..db.models import Contact, ContactSent
19from ..util.types import (
20 ClientType,
21 ContactMessage,
22 ContactSticker,
23 ExtraAttrMapping,
24 HoleBound,
25 MessageOrPresenceTypeVar,
26)
28if TYPE_CHECKING:
29 from ..command.base import ContactCommand
30 from ..core.session import BaseSession
31 from ..room.participant import LegacyParticipant
32 from .roster import LegacyRoster
35class _ContactProps(TypedDict, total=False):
36 name: str | None
37 is_friend: bool
38 full_name: str | None
39 birthday: datetime.date | None
40 bio: str | None
41 url: str | None
42 email: str | None
43 country: str | None
44 locality: str | None
45 pronouns: str | None
46 client_type: ClientType
47 online: bool
48 pshow: PresenceShows | Literal["available"] | None
49 pstatus: str | None
50 last_seen: datetime.datetime | None
51 added_to_roster: bool
52 phones: list[str] | None
55class LegacyContact[T: ExtraAttrMapping = Any](
56 ImageMixin[T, _ContactProps],
57 ContactAccountDiscoMixin,
58 FullCarbonMixin,
59 RecipientMixin,
60):
61 """
62 This class centralizes actions in relation to a specific legacy contact.
64 You shouldn't create instances of contacts manually, but rather rely on
65 :meth:`.LegacyRoster.get_or_create` to ensure that contact instances are
66 singletons. The :class:`.LegacyRoster` instance of a session is accessible
67 through the :attr:`.BaseSession.contacts` attribute.
69 Typically, your plugin should have methods hook to the legacy events and
70 call appropriate methods here to transmit the "legacy action" to the xmpp
71 user. This should look like this:
73 .. code-block:python
75 class Session(BaseSession):
76 ...
78 async def on_cool_chat_network_new_text_message(self, legacy_msg_event):
79 contact = self.contacts.get_or_create(legacy_msg_event.from)
80 contact.send_text(legacy_msg_event.text)
82 async def on_cool_chat_network_new_typing_event(self, legacy_typing_event):
83 contact = self.contacts.get_or_create(legacy_msg_event.from)
84 contact.composing()
85 ...
87 Use ``carbon=True`` as a keyword arg for methods to represent an action FROM
88 the user TO the contact, typically when the user uses an official client to
89 do an action such as sending a message or marking as message as read.
90 This will use :xep:`0363` to impersonate the XMPP user in order.
91 """
93 RESOURCE: str = "slidge"
94 """
95 A full JID, including a resource part is required for chat states (and maybe other stuff)
96 to work properly. This is the name of the resource the contacts will use.
97 """
98 PROPAGATE_PRESENCE_TO_ROOMS = True
100 _mtype: MessageTypes = "chat"
101 _can_send_carbon = True
102 is_participant: Literal[False] = False
103 is_group: Literal[False] = False
105 _ONLY_SEND_PRESENCE_CHANGES = True
107 _STRIP_SHORT_DELAY = True
108 _NON_FRIEND_PRESENCES_FILTER: ClassVar[set[str]] = {"subscribe", "unsubscribed"}
110 _INVITATION_RECIPIENT = True
112 _commands: ClassVar[dict[str, type[ContactCommand[LegacyContact]]]] = {}
113 _commands_chat: ClassVar[dict[str, type[ContactCommand[LegacyContact]]]] = {}
115 _stored: Contact
116 _model: Contact
118 extra_attributes: T
119 _session: BaseSession[LegacyRoster[Self], Any]
121 def __init__(
122 self, session: BaseSession[LegacyRoster[Self], Any], stored: Contact
123 ) -> None:
124 self._session = session
125 self.xmpp = session.xmpp
126 self._stored = stored
127 self._set_logger()
128 self.__batch_set_presence = False
129 super().__init__()
131 @property
132 def is_user(self) -> bool:
133 return self.legacy_id == self._session.contacts.user_legacy_id
135 def _recipient_pk(self) -> int:
136 return self._stored.id
138 @property
139 def name(self) -> str:
140 """
141 Friendly name of the contact, as it should appear in the user's roster
142 """
143 return self._stored.nick or ""
145 @name.setter
146 def name(self, n: str | None) -> None:
147 if not self._update_stored_attribute(nick=n):
148 return
149 self._set_logger()
150 if self.is_friend:
151 self.xmpp.pubsub.broadcast_nick(
152 user_jid=self.user_jid, jid=self.jid.bare, nick=n
153 )
154 for p in self.participants:
155 p.nickname = n or str(self.legacy_id)
157 @property
158 def is_friend(self) -> bool:
159 """
160 Whether this contact is a friend or not.
161 """
162 return self._stored.is_friend
164 @is_friend.setter
165 def is_friend(self, value: bool) -> None:
166 if (
167 self._update_stored_attribute(is_friend=value)
168 and value
169 and not self._session.contacts.is_filling
170 ):
171 # TODO: should we broadcast avatar and nick at this stage?
172 # maybe it's actually triggered by receiving the user's presence?
173 self._session.create_task(
174 self._session.contacts._add_to_roster(self), f"add {self} to roster"
175 )
177 @property
178 def full_name(self) -> str | None:
179 return self._stored.full_name
181 @full_name.setter
182 def full_name(self, full_name: str | None) -> None:
183 self._update_stored_attribute(full_name=full_name)
185 @property
186 def birthday(self) -> datetime.date | None:
187 return self._stored.birthday
189 @birthday.setter
190 def birthday(self, birthday: datetime.date | None) -> None:
191 """
192 The birthday of this contact. Only appears in the VCard (:xep:`0292`).
193 """
194 self._update_stored_attribute(birthday=birthday)
196 @property
197 def bio(self) -> str | None:
198 return self._stored.bio
200 @bio.setter
201 def bio(self, bio: str | None) -> None:
202 self._update_stored_attribute(bio=bio)
204 @property
205 def url(self) -> str | None:
206 return self._stored.url
208 @url.setter
209 def url(self, url: str | None) -> None:
210 self._update_stored_attribute(url=url)
212 @property
213 def email(self) -> str | None:
214 return self._stored.email
216 @email.setter
217 def email(self, email: str | None) -> None:
218 self._update_stored_attribute(email=email)
220 @property
221 def country(self) -> str | None:
222 return self._stored.country
224 @country.setter
225 def country(self, country: str | None) -> None:
226 self._update_stored_attribute(country=country)
228 @property
229 def locality(self) -> str | None:
230 return self._stored.locality
232 @locality.setter
233 def locality(self, locality: str | None) -> None:
234 self._update_stored_attribute(locality=locality)
236 @property
237 def pronouns(self) -> str | None:
238 return self._stored.pronouns
240 @pronouns.setter
241 def pronouns(self, pronouns: str | None) -> None:
242 self._update_stored_attribute(pronouns=pronouns)
244 @property
245 def phones(self) -> list[str] | None:
246 return self._stored.phones
248 @phones.setter
249 def phones(self, phones: list[str] | None) -> None:
250 self._update_stored_attribute(phones=phones)
252 @property
253 def client_type(self) -> ClientType:
254 """
255 The client type of this contact, cf https://xmpp.org/registrar/disco-categories.html#client
257 Default is "pc".
258 """
259 return self._stored.client_type
261 @client_type.setter
262 def client_type(self, value: ClientType) -> None:
263 self._update_stored_attribute(client_type=value)
265 async def on_message(self, message: ContactMessage) -> str | None:
266 """
267 Triggered when the user sends a message to this :term:`Contact`.
269 :return: A message ID of that can be used later to further reference
270 this message (reactions, read marks, etc.).
271 """
272 raise NotImplementedError
274 async def on_sticker(self, sticker: ContactSticker) -> str | None:
275 """
276 Triggered when the user sends a sticker to this :term:`Contact`.
278 :param sticker: The sticker sent by the user.
280 :return: A message ID of that can be used later to further reference
281 this message (reactions, read marks, etc.).
282 """
283 raise NotImplementedError
285 @property
286 def jid(self) -> JID:
287 jid = self._stored.jid
288 jid.resource = self.RESOURCE
289 return jid
291 @property
292 def legacy_id(self) -> str:
293 return self._stored.legacy_id
295 async def get_vcard(self, fetch: bool = True) -> VCard4:
296 if fetch and not self._stored.more_metadata_fetched:
297 await self.fill_extra_info()
298 with self.xmpp.store.session() as orm:
299 orm.add(self._stored)
300 return self._stored.vcard(self.xmpp.boundjid.bare)
302 @property
303 def added_to_roster(self) -> bool:
304 return self._stored.added_to_roster
306 @added_to_roster.setter
307 def added_to_roster(self, value: bool) -> None:
308 self._update_stored_attribute(added_to_roster=value)
310 async def fill_extra_info(self) -> None:
311 pass
313 @property
314 def participants(self) -> Iterator[LegacyParticipant[Self]]:
315 self._stored.load_participants(self.xmpp.store.session)
316 for p in self._stored.participants:
317 room = self._session.rooms._from_store(p.room)
318 yield room.participants._from_store(p, contact=self)
320 @property
321 def DISCO_TYPE(self) -> str:
322 return self.client_type
324 def _set_logger(self) -> None:
325 self.log = logging.getLogger(f"{self.user_jid.bare}:contact:{self}")
327 def __repr__(self) -> str:
328 return f"<Contact #{self._stored.id} '{self.name}' ({self.legacy_id} - {self.jid.user})'>"
330 def __propagate_to_participants(self, stanza: Presence) -> None:
331 if not self.PROPAGATE_PRESENCE_TO_ROOMS:
332 return
333 self._stored.ensure_loaded("presence", self.xmpp.store.session)
334 for part in self.participants:
335 part._send(
336 part._make_presence(
337 pshow=self._stored.pshow,
338 pstatus=self._stored.pstatus,
339 last_seen=self._stored.last_seen,
340 )
341 )
343 def _send(
344 self,
345 stanza: MessageOrPresenceTypeVar,
346 carbon: bool = False,
347 nick: bool = False,
348 **send_kwargs: Any, # noqa:ANN401
349 ) -> MessageOrPresenceTypeVar:
350 carbon = carbon or self.is_user
352 if carbon and isinstance(stanza, Message):
353 stanza["to"] = self.jid.bare
354 stanza["from"] = self.user_jid
355 self._privileged_send(stanza)
356 return stanza
358 if isinstance(stanza, Presence):
359 self.__propagate_to_participants(stanza)
360 if (
361 not self.is_friend
362 and stanza["type"] not in self._NON_FRIEND_PRESENCES_FILTER
363 ):
364 return stanza
365 if self.name and (nick or not self.is_friend):
366 n = self.xmpp.plugin["xep_0172"].stanza.UserNick()
367 n["nick"] = self.name
368 stanza.append(n)
369 if self.xmpp.MARK_ALL_MESSAGES and is_markable(stanza):
370 with self.xmpp.store.session(expire_on_commit=False) as orm:
371 self._stored = orm.merge(self._stored)
372 exists = (
373 orm.query(ContactSent)
374 .filter_by(contact_id=self._stored.id, msg_id=stanza["id"])
375 .first()
376 )
377 if exists:
378 self.log.warning(
379 "Contact has already sent message %s", stanza["id"]
380 )
381 else:
382 new = ContactSent(contact=self._stored, msg_id=stanza["id"])
383 orm.add(new)
384 self._stored.sent_order.append(new)
385 orm.commit()
386 stanza["to"] = self.user_jid
387 stanza.send()
388 return stanza
390 def _store_last_sent_msg(
391 self, legacy_id: str, when: datetime.datetime | None
392 ) -> None:
393 with self.xmpp.store.session(expire_on_commit=False) as orm:
394 orm.execute(
395 sa.update(Contact)
396 .where(Contact.id == self._recipient_pk())
397 .values(
398 last_sent_msg_legacy_id=legacy_id,
399 last_sent_msg_date=when or datetime.datetime.now(tz=datetime.UTC),
400 )
401 )
402 orm.commit()
404 def pop_unread_xmpp_ids_up_to(self, horizon_xmpp_id: str) -> list[str]:
405 """
406 Return XMPP msg ids sent by this contact up to a given XMPP msg id.
408 Legacy modules have no reason to use this, but it is used by slidge core
409 for legacy networks that need to mark all messages as read (most XMPP
410 clients only send a read marker for the latest message).
412 This has side effects, if the horizon XMPP id is found, messages up to
413 this horizon are cleared, to avoid sending the same read mark twice.
415 :param horizon_xmpp_id: The latest message
416 :return: A list of XMPP ids up to horizon_xmpp_id, included
417 """
418 with self.xmpp.store.session() as orm:
419 assert self._stored.id is not None
420 ids = self.xmpp.store.contacts.pop_sent_up_to(
421 orm, self._stored.id, horizon_xmpp_id
422 )
423 orm.commit()
424 return ids
426 def _post_image_update(
427 self, cached_avatar: CachedAvatar | None, *, attr: str = "avatar"
428 ) -> None:
429 if self.is_friend:
430 self._session.create_task(
431 self._session.xmpp.pubsub.broadcast_avatar(
432 self.jid.bare, self._session.user_jid, cached_avatar
433 ),
434 name=f"Post avatar update of {self}",
435 )
436 self.log.debug("Propagating avatar to participants")
437 for p in self._stored.get_participants_for_avatar_propagation(
438 self.xmpp.store.session
439 ):
440 room = self._session.rooms._from_store(p.room)
441 part = room.participants._from_store(p, contact=self)
442 part.send_last_presence(force=True)
443 self.log.debug("Propagating avatar to participants: done")
445 async def _broadcast_pubsub_items(self) -> None:
446 if not self.is_friend:
447 return
449 await self._session.contacts.ready
451 cached_avatar = self.get_cached_avatar()
452 if cached_avatar is not None:
453 await self.xmpp.pubsub.broadcast_avatar(
454 self.jid.bare, self._session.user_jid, cached_avatar
455 )
456 nick = self.name
458 if nick is not None:
459 self.xmpp.pubsub.broadcast_nick(
460 self._session.user_jid,
461 self.jid.bare,
462 nick,
463 )
465 def send_friend_request(self, text: str | None = None) -> None:
466 presence = self._make_presence(ptype="subscribe", pstatus=text, bare=True)
467 self._send(presence, nick=True)
469 async def accept_friend_request(self, text: str | None = None) -> None:
470 """
471 Call this to signify that this Contact has accepted to be a friend
472 of the user.
474 :param text: Optional message from the friend to the user
475 """
476 self.is_friend = True
477 self.added_to_roster = True
478 self.log.debug("Accepting friend request")
479 presence = self._make_presence(ptype="subscribed", pstatus=text, bare=True)
480 self._send(presence, nick=True)
481 self.send_last_presence()
482 await self._broadcast_pubsub_items()
483 self.log.debug("Accepted friend request")
485 def reject_friend_request(self, text: str | None = None) -> None:
486 """
487 Call this to signify that this Contact has refused to be a contact
488 of the user (or that they don't want to be friends anymore)
490 :param text: Optional message from the non-friend to the user
491 """
492 presence = self._make_presence(ptype="unsubscribed", pstatus=text, bare=True)
494 self._send(presence, nick=True)
495 self.is_friend = False
497 async def on_friend_request(self, text: str = "") -> None:
498 """
499 Called when receiving a "subscribe" presence, ie, "I would like to add
500 you to my contacts/friends", from the user to this contact.
502 In XMPP terms: "I would like to receive your presence updates"
504 This is only called if self.is_friend = False. If self.is_friend = True,
505 slidge will automatically "accept the friend request", ie, reply with
506 a "subscribed" presence.
508 When called, a 'friend request event' should be sent to the legacy
509 service, and when the contact responds, you should either call
510 self.accept_subscription() or self.reject_subscription()
511 """
513 async def on_friend_delete(self, text: str = "") -> None:
514 """
515 Called when receiving an "unsubscribed" presence, ie, "I would like to
516 remove you to my contacts/friends" or "I refuse your friend request"
517 from the user to this contact.
519 In XMPP terms: "You won't receive my presence updates anymore (or you
520 never have)".
521 """
523 async def on_friend_accept(self) -> None:
524 """
525 Called when receiving a "subscribed" presence, ie, "I accept to be
526 your/confirm that you are my friend" from the user to this contact.
528 In XMPP terms: "You will receive my presence updates".
529 """
531 def unsubscribe(self) -> None:
532 """
533 (internal use by slidge)
535 Send an "unsubscribe", "unsubscribed", "unavailable" presence sequence
536 from this contact to the user, ie, "this contact has removed you from
537 their 'friends'".
538 """
539 for ptype in "unsubscribe", "unsubscribed", "unavailable":
540 self.xmpp.send_presence(pfrom=self.jid, pto=self.user_jid.bare, ptype=ptype)
542 def _on_delete(self) -> None:
543 self.unsubscribe()
545 def _make_presence(
546 self,
547 *,
548 last_seen: datetime.datetime | None = None,
549 **presence_kwargs: Any, # noqa:ANN401
550 ) -> Presence:
551 p = super()._make_presence(last_seen=last_seen, **presence_kwargs)
552 caps = self.xmpp.plugin["xep_0115"]
554 if p.get_from().resource and (ver := self.xmpp.get_caps_ver(self.client_type)):
555 p["caps"]["node"] = caps.caps_node
556 p["caps"]["hash"] = caps.hash
557 p["caps"]["ver"] = ver
558 return p
560 async def backfill(self, after: HoleBound | None) -> None:
561 """
562 This method can be overridden to implement history fetching for this
563 contact.
565 Since the message archive (:xep:`0313`) is managed by the XMPP server
566 of the user, there are several caveats.
568 - We cannot prevent the XMPP server from injecting a :xep:`0203`
569 timestamp at the time it receives the messages. Most XMPP clients
570 will use that timestamp instead of the one we inject and thus message
571 ordering may be messed up under certain circumstances.
572 - We cannot know if a message is already in this archive, so legacy
573 modules should ensure that this does not send message that have
574 already sent before or there will be duplicates.
575 - Related to the previous point, if a legacy network APIs returns a
576 message while iterating over a "fetch history" call and also pass
577 it as a live message, you may end up with duplicates.
579 For these reasons, we recommend sending only recent messages, mostly
580 messages that could have been missed when slidge was down.
582 NB: if the legacy client receives "message while it was down" as live
583 messages on startup, this is pretty much useless.
585 :param after: Last message to or from this contact that slidge saw
586 passing through. If ``None``, it means slidge never saw any message
587 from this contact.
588 """
591def is_markable(stanza: Message | Presence) -> bool:
592 if isinstance(stanza, Presence):
593 return False
594 return bool(stanza["body"])
597log = logging.getLogger(__name__)