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

1from __future__ import annotations 

2 

3import datetime 

4import logging 

5from collections.abc import Iterator 

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

7 

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 

13 

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) 

27 

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 

33 

34 

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 

53 

54 

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. 

63 

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. 

68 

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: 

72 

73 .. code-block:python 

74 

75 class Session(BaseSession): 

76 ... 

77 

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) 

81 

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 ... 

86 

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 """ 

92 

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 

99 

100 _mtype: MessageTypes = "chat" 

101 _can_send_carbon = True 

102 is_participant: Literal[False] = False 

103 is_group: Literal[False] = False 

104 

105 _ONLY_SEND_PRESENCE_CHANGES = True 

106 

107 _STRIP_SHORT_DELAY = True 

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

109 

110 _INVITATION_RECIPIENT = True 

111 

112 _commands: ClassVar[dict[str, type[ContactCommand[LegacyContact]]]] = {} 

113 _commands_chat: ClassVar[dict[str, type[ContactCommand[LegacyContact]]]] = {} 

114 

115 _stored: Contact 

116 _model: Contact 

117 

118 extra_attributes: T 

119 _session: BaseSession[LegacyRoster[Self], Any] 

120 

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__() 

130 

131 @property 

132 def is_user(self) -> bool: 

133 return self.legacy_id == self._session.contacts.user_legacy_id 

134 

135 def _recipient_pk(self) -> int: 

136 return self._stored.id 

137 

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 "" 

144 

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) 

156 

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 

163 

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 ) 

176 

177 @property 

178 def full_name(self) -> str | None: 

179 return self._stored.full_name 

180 

181 @full_name.setter 

182 def full_name(self, full_name: str | None) -> None: 

183 self._update_stored_attribute(full_name=full_name) 

184 

185 @property 

186 def birthday(self) -> datetime.date | None: 

187 return self._stored.birthday 

188 

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) 

195 

196 @property 

197 def bio(self) -> str | None: 

198 return self._stored.bio 

199 

200 @bio.setter 

201 def bio(self, bio: str | None) -> None: 

202 self._update_stored_attribute(bio=bio) 

203 

204 @property 

205 def url(self) -> str | None: 

206 return self._stored.url 

207 

208 @url.setter 

209 def url(self, url: str | None) -> None: 

210 self._update_stored_attribute(url=url) 

211 

212 @property 

213 def email(self) -> str | None: 

214 return self._stored.email 

215 

216 @email.setter 

217 def email(self, email: str | None) -> None: 

218 self._update_stored_attribute(email=email) 

219 

220 @property 

221 def country(self) -> str | None: 

222 return self._stored.country 

223 

224 @country.setter 

225 def country(self, country: str | None) -> None: 

226 self._update_stored_attribute(country=country) 

227 

228 @property 

229 def locality(self) -> str | None: 

230 return self._stored.locality 

231 

232 @locality.setter 

233 def locality(self, locality: str | None) -> None: 

234 self._update_stored_attribute(locality=locality) 

235 

236 @property 

237 def pronouns(self) -> str | None: 

238 return self._stored.pronouns 

239 

240 @pronouns.setter 

241 def pronouns(self, pronouns: str | None) -> None: 

242 self._update_stored_attribute(pronouns=pronouns) 

243 

244 @property 

245 def phones(self) -> list[str] | None: 

246 return self._stored.phones 

247 

248 @phones.setter 

249 def phones(self, phones: list[str] | None) -> None: 

250 self._update_stored_attribute(phones=phones) 

251 

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 

256 

257 Default is "pc". 

258 """ 

259 return self._stored.client_type 

260 

261 @client_type.setter 

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

263 self._update_stored_attribute(client_type=value) 

264 

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

266 """ 

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

268 

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 

273 

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

275 """ 

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

277 

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

279 

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 

284 

285 @property 

286 def jid(self) -> JID: 

287 jid = self._stored.jid 

288 jid.resource = self.RESOURCE 

289 return jid 

290 

291 @property 

292 def legacy_id(self) -> str: 

293 return self._stored.legacy_id 

294 

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) 

301 

302 @property 

303 def added_to_roster(self) -> bool: 

304 return self._stored.added_to_roster 

305 

306 @added_to_roster.setter 

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

308 self._update_stored_attribute(added_to_roster=value) 

309 

310 async def fill_extra_info(self) -> None: 

311 pass 

312 

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) 

319 

320 @property 

321 def DISCO_TYPE(self) -> str: 

322 return self.client_type 

323 

324 def _set_logger(self) -> None: 

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

326 

327 def __repr__(self) -> str: 

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

329 

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 ) 

342 

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 

351 

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 

357 

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 

389 

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() 

403 

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. 

407 

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). 

411 

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. 

414 

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 

425 

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") 

444 

445 async def _broadcast_pubsub_items(self) -> None: 

446 if not self.is_friend: 

447 return 

448 

449 await self._session.contacts.ready 

450 

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 

457 

458 if nick is not None: 

459 self.xmpp.pubsub.broadcast_nick( 

460 self._session.user_jid, 

461 self.jid.bare, 

462 nick, 

463 ) 

464 

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) 

468 

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. 

473 

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") 

484 

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) 

489 

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

491 """ 

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

493 

494 self._send(presence, nick=True) 

495 self.is_friend = False 

496 

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. 

501 

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

503 

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. 

507 

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 """ 

512 

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. 

518 

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

520 never have)". 

521 """ 

522 

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. 

527 

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

529 """ 

530 

531 def unsubscribe(self) -> None: 

532 """ 

533 (internal use by slidge) 

534 

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) 

541 

542 def _on_delete(self) -> None: 

543 self.unsubscribe() 

544 

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"] 

553 

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 

559 

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

561 """ 

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

563 contact. 

564 

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

566 of the user, there are several caveats. 

567 

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. 

578 

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

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

581 

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

583 messages on startup, this is pretty much useless. 

584 

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 """ 

589 

590 

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

592 if isinstance(stanza, Presence): 

593 return False 

594 return bool(stanza["body"]) 

595 

596 

597log = logging.getLogger(__name__)