Coverage for slidge / core / session.py: 85%

259 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-02-15 09:02 +0000

1import asyncio 

2import logging 

3from collections.abc import Iterable 

4from typing import ( 

5 TYPE_CHECKING, 

6 Any, 

7 Generic, 

8 NamedTuple, 

9 Optional, 

10 Union, 

11 cast, 

12) 

13 

14import aiohttp 

15import sqlalchemy as sa 

16from slixmpp import JID, Message 

17from slixmpp.exceptions import XMPPError 

18from slixmpp.types import PresenceShows, ResourceDict 

19 

20from ..command import SearchResult 

21from ..contact import LegacyContact, LegacyRoster 

22from ..db.models import Contact, GatewayUser 

23from ..group.bookmarks import LegacyBookmarks 

24from ..group.room import LegacyMUC 

25from ..util import ABCSubclassableOnceAtMost 

26from ..util.lock import NamedLockMixin 

27from ..util.types import ( 

28 LegacyGroupIdType, 

29 LegacyMessageType, 

30 LegacyThreadType, 

31 LinkPreview, 

32 Mention, 

33 PseudoPresenceShow, 

34 RecipientType, 

35 Sticker, 

36) 

37from ..util.util import deprecated, noop_coro 

38 

39if TYPE_CHECKING: 

40 from ..group.participant import LegacyParticipant 

41 from ..util.types import Sender 

42 from .gateway import BaseGateway 

43 

44 

45class CachedPresence(NamedTuple): 

46 status: str | None 

47 show: str | None 

48 kwargs: dict[str, Any] 

49 

50 

51class BaseSession( 

52 Generic[LegacyMessageType, RecipientType], 

53 NamedLockMixin, 

54 metaclass=ABCSubclassableOnceAtMost, 

55): 

56 """ 

57 The session of a registered :term:`User`. 

58 

59 Represents a gateway user logged in to the legacy network and performing actions. 

60 

61 Will be instantiated automatically on slidge startup for each registered user, 

62 or upon registration for new (validated) users. 

63 

64 Must be subclassed for a functional :term:`Legacy Module`. 

65 """ 

66 

67 """ 

68 Since we cannot set the XMPP ID of messages sent by XMPP clients, we need to keep a mapping 

69 between XMPP IDs and legacy message IDs if we want to further refer to a message that was sent 

70 by the user. This also applies to 'carboned' messages, ie, messages sent by the user from 

71 the official client of a legacy network. 

72 """ 

73 

74 xmpp: "BaseGateway" 

75 """ 

76 The gateway instance singleton. Use it for low-level XMPP calls or custom methods that are not 

77 session-specific. 

78 """ 

79 

80 MESSAGE_IDS_ARE_THREAD_IDS = False 

81 """ 

82 Set this to True if the legacy service uses message IDs as thread IDs, 

83 eg Mattermost, where you can only 'create a thread' by replying to the message, 

84 in which case the message ID is also a thread ID (and all messages are potential 

85 threads). 

86 """ 

87 SPECIAL_MSG_ID_PREFIX: str | None = None 

88 """ 

89 If you set this, XMPP message IDs starting with this won't be converted to legacy ID, 

90 but passed as is to :meth:`.on_react`, and usual checks for emoji restriction won't be 

91 applied. 

92 This can be used to implement voting in polls in a hacky way. 

93 """ 

94 

95 _roster_cls: type[LegacyRoster] 

96 _bookmarks_cls: type[LegacyBookmarks] 

97 

98 def __init__(self, user: GatewayUser) -> None: 

99 super().__init__() 

100 self.user = user 

101 self.log = logging.getLogger(user.jid.bare) 

102 

103 self.ignore_messages = set[str]() 

104 

105 self.contacts = self._roster_cls(self) 

106 self.is_logging_in = False 

107 self._logged = False 

108 self.__reset_ready() 

109 

110 self.bookmarks = self._bookmarks_cls(self) 

111 

112 self.thread_creation_lock = asyncio.Lock() 

113 

114 self.__cached_presence: CachedPresence | None = None 

115 

116 self.__tasks = set[asyncio.Task]() 

117 

118 @property 

119 def user_jid(self) -> JID: 

120 return self.user.jid 

121 

122 @property 

123 def user_pk(self) -> int: 

124 return self.user.id 

125 

126 @property 

127 def http(self) -> aiohttp.ClientSession: 

128 return self.xmpp.http 

129 

130 def __remove_task(self, fut) -> None: 

131 self.log.debug("Removing fut %s", fut) 

132 self.__tasks.remove(fut) 

133 

134 def create_task(self, coro, name: str | None = None) -> asyncio.Task: 

135 task = self.xmpp.loop.create_task(coro, name=name) 

136 self.__tasks.add(task) 

137 self.log.debug("Creating task %s", task) 

138 task.add_done_callback(lambda _: self.__remove_task(task)) 

139 return task 

140 

141 def cancel_all_tasks(self) -> None: 

142 for task in self.__tasks: 

143 task.cancel() 

144 

145 async def login(self) -> str | None: 

146 """ 

147 Logs in the gateway user to the legacy network. 

148 

149 Triggered when the gateway start and on user registration. 

150 It is recommended that this function returns once the user is logged in, 

151 so if you need to await forever (for instance to listen to incoming events), 

152 it's a good idea to wrap your listener in an asyncio.Task. 

153 

154 :return: Optionally, a text to use as the gateway status, e.g., "Connected as 'dude@legacy.network'" 

155 """ 

156 raise NotImplementedError 

157 

158 async def logout(self) -> None: 

159 """ 

160 Logs out the gateway user from the legacy network. 

161 

162 Called on gateway shutdown. 

163 """ 

164 raise NotImplementedError 

165 

166 async def on_text( 

167 self, 

168 chat: RecipientType, 

169 text: str, 

170 *, 

171 reply_to_msg_id: LegacyMessageType | None = None, 

172 reply_to_fallback_text: str | None = None, 

173 reply_to: Optional["Sender"] = None, 

174 thread: LegacyThreadType | None = None, 

175 link_previews: Iterable[LinkPreview] = (), 

176 mentions: list[Mention] | None = None, 

177 ) -> LegacyMessageType | None: 

178 """ 

179 Triggered when the user sends a text message from XMPP to a bridged entity, e.g. 

180 to ``translated_user_name@slidge.example.com``, or ``translated_group_name@slidge.example.com`` 

181 

182 Override this and implement sending a message to the legacy network in this method. 

183 

184 :param text: Content of the message 

185 :param chat: Recipient of the message. :class:`.LegacyContact` instance for 1:1 chat, 

186 :class:`.MUC` instance for groups. 

187 :param reply_to_msg_id: A legacy message ID if the message references (quotes) 

188 another message (:xep:`0461`) 

189 :param reply_to_fallback_text: Content of the quoted text. Not necessarily set 

190 by XMPP clients 

191 :param reply_to: Author of the quoted message. :class:`LegacyContact` instance for 

192 1:1 chat, :class:`LegacyParticipant` instance for groups. 

193 If `None`, should be interpreted as a self-reply if reply_to_msg_id is not None. 

194 :param link_previews: A list of sender-generated link previews. 

195 At the time of writing, only `Cheogram <https://wiki.soprani.ca/CheogramApp/LinkPreviews>`_ 

196 supports it. 

197 :param mentions: (only for groups) A list of Contacts mentioned by their 

198 nicknames. 

199 :param thread: 

200 

201 :return: An ID of some sort that can be used later to ack and mark the message 

202 as read by the user 

203 """ 

204 raise NotImplementedError 

205 

206 send_text = deprecated("BaseSession.send_text", on_text) 

207 

208 async def on_file( 

209 self, 

210 chat: RecipientType, 

211 url: str, 

212 *, 

213 http_response: aiohttp.ClientResponse, 

214 reply_to_msg_id: LegacyMessageType | None = None, 

215 reply_to_fallback_text: str | None = None, 

216 reply_to: Union["LegacyContact", "LegacyParticipant"] | None = None, 

217 thread: LegacyThreadType | None = None, 

218 ) -> LegacyMessageType | None: 

219 """ 

220 Triggered when the user sends a file using HTTP Upload (:xep:`0363`) 

221 

222 :param url: URL of the file 

223 :param chat: See :meth:`.BaseSession.on_text` 

224 :param http_response: The HTTP GET response object on the URL 

225 :param reply_to_msg_id: See :meth:`.BaseSession.on_text` 

226 :param reply_to_fallback_text: See :meth:`.BaseSession.on_text` 

227 :param reply_to: See :meth:`.BaseSession.on_text` 

228 :param thread: 

229 

230 :return: An ID of some sort that can be used later to ack and mark the message 

231 as read by the user 

232 """ 

233 raise NotImplementedError 

234 

235 send_file = deprecated("BaseSession.send_file", on_file) 

236 

237 async def on_sticker( 

238 self, 

239 chat: RecipientType, 

240 sticker: Sticker, 

241 *, 

242 reply_to_msg_id: LegacyMessageType | None = None, 

243 reply_to_fallback_text: str | None = None, 

244 reply_to: Union["LegacyContact", "LegacyParticipant"] | None = None, 

245 thread: LegacyThreadType | None = None, 

246 ) -> LegacyMessageType | None: 

247 """ 

248 Triggered when the user sends a file using HTTP Upload (:xep:`0363`) 

249 

250 :param chat: See :meth:`.BaseSession.on_text` 

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

252 :param reply_to_msg_id: See :meth:`.BaseSession.on_text` 

253 :param reply_to_fallback_text: See :meth:`.BaseSession.on_text` 

254 :param reply_to: See :meth:`.BaseSession.on_text` 

255 :param thread: 

256 

257 :return: An ID of some sort that can be used later to ack and mark the message 

258 as read by the user 

259 """ 

260 raise NotImplementedError 

261 

262 async def on_active( 

263 self, chat: RecipientType, thread: LegacyThreadType | None = None 

264 ): 

265 """ 

266 Triggered when the user sends an 'active' chat state (:xep:`0085`) 

267 

268 :param chat: See :meth:`.BaseSession.on_text` 

269 :param thread: 

270 """ 

271 raise NotImplementedError 

272 

273 active = deprecated("BaseSession.active", on_active) 

274 

275 async def on_inactive( 

276 self, chat: RecipientType, thread: LegacyThreadType | None = None 

277 ): 

278 """ 

279 Triggered when the user sends an 'inactive' chat state (:xep:`0085`) 

280 

281 :param chat: See :meth:`.BaseSession.on_text` 

282 :param thread: 

283 """ 

284 raise NotImplementedError 

285 

286 inactive = deprecated("BaseSession.inactive", on_inactive) 

287 

288 async def on_composing( 

289 self, chat: RecipientType, thread: LegacyThreadType | None = None 

290 ): 

291 """ 

292 Triggered when the user starts typing in a legacy chat (:xep:`0085`) 

293 

294 :param chat: See :meth:`.BaseSession.on_text` 

295 :param thread: 

296 """ 

297 raise NotImplementedError 

298 

299 composing = deprecated("BaseSession.composing", on_composing) 

300 

301 async def on_paused( 

302 self, chat: RecipientType, thread: LegacyThreadType | None = None 

303 ): 

304 """ 

305 Triggered when the user pauses typing in a legacy chat (:xep:`0085`) 

306 

307 :param chat: See :meth:`.BaseSession.on_text` 

308 :param thread: 

309 """ 

310 raise NotImplementedError 

311 

312 paused = deprecated("BaseSession.paused", on_paused) 

313 

314 async def on_gone( 

315 self, chat: RecipientType, thread: LegacyThreadType | None = None 

316 ): 

317 """ 

318 Triggered when the user is "gone" in a legacy chat (:xep:`0085`) 

319 

320 :param chat: See :meth:`.BaseSession.on_text` 

321 :param thread: 

322 """ 

323 raise NotImplementedError 

324 

325 async def on_displayed( 

326 self, 

327 chat: RecipientType, 

328 legacy_msg_id: LegacyMessageType, 

329 thread: LegacyThreadType | None = None, 

330 ): 

331 """ 

332 Triggered when the user reads a message in a legacy chat. (:xep:`0333`) 

333 

334 This is only possible if a valid ``legacy_msg_id`` was passed when 

335 transmitting a message from a legacy chat to the user, eg in 

336 :meth:`slidge.contact.LegacyContact.send_text` 

337 or 

338 :meth:`slidge.group.LegacyParticipant.send_text`. 

339 

340 :param chat: See :meth:`.BaseSession.on_text` 

341 :param legacy_msg_id: Identifier of the message/ 

342 :param thread: 

343 """ 

344 raise NotImplementedError 

345 

346 displayed = deprecated("BaseSession.displayed", on_displayed) 

347 

348 async def on_correct( 

349 self, 

350 chat: RecipientType, 

351 text: str, 

352 legacy_msg_id: LegacyMessageType, 

353 *, 

354 thread: LegacyThreadType | None = None, 

355 link_previews: Iterable[LinkPreview] = (), 

356 mentions: list[Mention] | None = None, 

357 ) -> LegacyMessageType | None: 

358 """ 

359 Triggered when the user corrects a message using :xep:`0308` 

360 

361 This is only possible if a valid ``legacy_msg_id`` was returned by 

362 :meth:`.on_text`. 

363 

364 :param chat: See :meth:`.BaseSession.on_text` 

365 :param text: The new text 

366 :param legacy_msg_id: Identifier of the edited message 

367 :param thread: 

368 :param link_previews: A list of sender-generated link previews. 

369 At the time of writing, only `Cheogram <https://wiki.soprani.ca/CheogramApp/LinkPreviews>`_ 

370 supports it. 

371 :param mentions: (only for groups) A list of Contacts mentioned by their 

372 nicknames. 

373 """ 

374 raise NotImplementedError 

375 

376 correct = deprecated("BaseSession.correct", on_correct) 

377 

378 async def on_react( 

379 self, 

380 chat: RecipientType, 

381 legacy_msg_id: LegacyMessageType, 

382 emojis: list[str], 

383 thread: LegacyThreadType | None = None, 

384 ): 

385 """ 

386 Triggered when the user sends message reactions (:xep:`0444`). 

387 

388 :param chat: See :meth:`.BaseSession.on_text` 

389 :param thread: 

390 :param legacy_msg_id: ID of the message the user reacts to 

391 :param emojis: Unicode characters representing reactions to the message ``legacy_msg_id``. 

392 An empty string means "no reaction", ie, remove all reactions if any were present before 

393 """ 

394 raise NotImplementedError 

395 

396 react = deprecated("BaseSession.react", on_react) 

397 

398 async def on_retract( 

399 self, 

400 chat: RecipientType, 

401 legacy_msg_id: LegacyMessageType, 

402 thread: LegacyThreadType | None = None, 

403 ): 

404 """ 

405 Triggered when the user retracts (:xep:`0424`) a message. 

406 

407 :param chat: See :meth:`.BaseSession.on_text` 

408 :param thread: 

409 :param legacy_msg_id: Legacy ID of the retracted message 

410 """ 

411 raise NotImplementedError 

412 

413 retract = deprecated("BaseSession.retract", on_retract) 

414 

415 async def on_presence( 

416 self, 

417 resource: str, 

418 show: PseudoPresenceShow, 

419 status: str, 

420 resources: dict[str, ResourceDict], 

421 merged_resource: ResourceDict | None, 

422 ): 

423 """ 

424 Called when the gateway component receives a presence, ie, when 

425 one of the user's clients goes online of offline, or changes its 

426 status. 

427 

428 :param resource: The XMPP client identifier, arbitrary string. 

429 :param show: The presence ``<show>``, if available. If the resource is 

430 just 'available' without any ``<show>`` element, this is an empty 

431 str. 

432 :param status: A status message, like a deeply profound quote, eg, 

433 "Roses are red, violets are blue, [INSERT JOKE]". 

434 :param resources: A summary of all the resources for this user. 

435 :param merged_resource: A global presence for the user account, 

436 following rules described in :meth:`merge_resources` 

437 """ 

438 raise NotImplementedError 

439 

440 presence = deprecated("BaseSession.presence", on_presence) 

441 

442 async def on_search(self, form_values: dict[str, str]) -> SearchResult | None: 

443 """ 

444 Triggered when the user uses Jabber Search (:xep:`0055`) on the component 

445 

446 Form values is a dict in which keys are defined in :attr:`.BaseGateway.SEARCH_FIELDS` 

447 

448 :param form_values: search query, defined for a specific plugin by overriding 

449 in :attr:`.BaseGateway.SEARCH_FIELDS` 

450 :return: 

451 """ 

452 raise NotImplementedError 

453 

454 search = deprecated("BaseSession.search", on_search) 

455 

456 async def on_avatar( 

457 self, 

458 bytes_: bytes | None, 

459 hash_: str | None, 

460 type_: str | None, 

461 width: int | None, 

462 height: int | None, 

463 ) -> None: 

464 """ 

465 Triggered when the user uses modifies their avatar via :xep:`0084`. 

466 

467 :param bytes_: The data of the avatar. According to the spec, this 

468 should always be a PNG, but some implementations do not respect 

469 that. If `None` it means the user has unpublished their avatar. 

470 :param hash_: The SHA1 hash of the avatar data. This is an identifier of 

471 the avatar. 

472 :param type_: The MIME type of the avatar. 

473 :param width: The width of the avatar image. 

474 :param height: The height of the avatar image. 

475 """ 

476 raise NotImplementedError 

477 

478 async def on_moderate( 

479 self, muc: LegacyMUC, legacy_msg_id: LegacyMessageType, reason: str | None 

480 ): 

481 """ 

482 Triggered when the user attempts to retract a message that was sent in 

483 a MUC using :xep:`0425`. 

484 

485 If retraction is not possible, this should raise the appropriate 

486 XMPPError with a human-readable message. 

487 

488 NB: the legacy module is responsible for calling 

489 :method:`LegacyParticipant.moderate` when this is successful, because 

490 slidge will acknowledge the moderation IQ, but will not send the 

491 moderation message from the MUC automatically. 

492 

493 :param muc: The MUC in which the message was sent 

494 :param legacy_msg_id: The legacy ID of the message to be retracted 

495 :param reason: Optionally, a reason for the moderation, given by the 

496 user-moderator. 

497 """ 

498 raise NotImplementedError 

499 

500 async def on_create_group( 

501 self, name: str, contacts: list[LegacyContact] 

502 ) -> LegacyGroupIdType: 

503 """ 

504 Triggered when the user request the creation of a group via the 

505 dedicated :term:`Command`. 

506 

507 :param name: Name of the group 

508 :param contacts: list of contacts that should be members of the group 

509 """ 

510 raise NotImplementedError 

511 

512 async def on_invitation( 

513 self, contact: LegacyContact, muc: LegacyMUC, reason: str | None 

514 ) -> None: 

515 """ 

516 Triggered when the user invites a :term:`Contact` to a legacy MUC via 

517 :xep:`0249`. 

518 

519 The default implementation calls :meth:`LegacyMUC.on_set_affiliation` 

520 with the 'member' affiliation. Override if you want to customize this 

521 behaviour. 

522 

523 :param contact: The invitee 

524 :param muc: The group 

525 :param reason: Optionally, a reason 

526 """ 

527 await muc.on_set_affiliation(contact, "member", reason, None) 

528 

529 async def on_leave_group(self, muc_legacy_id: LegacyGroupIdType): 

530 """ 

531 Triggered when the user leaves a group via the dedicated slidge command 

532 or the :xep:`0077` ``<remove />`` mechanism. 

533 

534 This should be interpreted as definitely leaving the group. 

535 

536 :param muc_legacy_id: The legacy ID of the group to leave 

537 """ 

538 raise NotImplementedError 

539 

540 async def on_preferences( 

541 self, previous: dict[str, Any], new: dict[str, Any] 

542 ) -> None: 

543 """ 

544 This is called when the user updates their preferences. 

545 

546 Override this if you need set custom preferences field and need to trigger 

547 something when a preference has changed. 

548 """ 

549 raise NotImplementedError 

550 

551 def __reset_ready(self) -> None: 

552 self.ready = self.xmpp.loop.create_future() 

553 

554 @property 

555 def logged(self): 

556 return self._logged 

557 

558 @logged.setter 

559 def logged(self, v: bool) -> None: 

560 self.is_logging_in = False 

561 self._logged = v 

562 if self.ready.done(): 

563 if v: 

564 return 

565 self.__reset_ready() 

566 self.shutdown(logout=False) 

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

568 self.xmpp.store.mam.reset_source(orm) 

569 self.xmpp.store.rooms.reset_updated(orm) 

570 self.xmpp.store.contacts.reset_updated(orm) 

571 orm.commit() 

572 else: 

573 if v: 

574 self.ready.set_result(True) 

575 

576 def __repr__(self) -> str: 

577 return f"<Session of {self.user_jid}>" 

578 

579 def shutdown(self, logout: bool = True) -> asyncio.Task: 

580 for m in self.bookmarks: 

581 m.shutdown() 

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

583 for jid in orm.execute( 

584 sa.select(Contact.jid).filter_by(user=self.user, is_friend=True) 

585 ).scalars(): 

586 pres = self.xmpp.make_presence( 

587 pfrom=jid, 

588 pto=self.user_jid, 

589 ptype="unavailable", 

590 pstatus="Gateway has shut down.", 

591 ) 

592 pres.send() 

593 if logout: 

594 return self.xmpp.loop.create_task(self.__logout()) 

595 else: 

596 return self.xmpp.loop.create_task(noop_coro()) 

597 

598 async def __logout(self) -> None: 

599 try: 

600 await self.logout() 

601 except NotImplementedError: 

602 pass 

603 except KeyboardInterrupt: 

604 pass 

605 

606 @staticmethod 

607 def legacy_to_xmpp_msg_id(legacy_msg_id: LegacyMessageType) -> str: 

608 """ 

609 Convert a legacy msg ID to a valid XMPP msg ID. 

610 Needed for read marks, retractions and message corrections. 

611 

612 The default implementation just converts the legacy ID to a :class:`str`, 

613 but this should be overridden in case some characters needs to be escaped, 

614 or to add some additional, 

615 :term:`legacy network <Legacy Network`>-specific logic. 

616 

617 :param legacy_msg_id: 

618 :return: A string that is usable as an XMPP stanza ID 

619 """ 

620 return str(legacy_msg_id) 

621 

622 legacy_msg_id_to_xmpp_msg_id = staticmethod( 

623 deprecated("BaseSession.legacy_msg_id_to_xmpp_msg_id", legacy_to_xmpp_msg_id) 

624 ) 

625 

626 @staticmethod 

627 def xmpp_to_legacy_msg_id(i: str) -> LegacyMessageType: 

628 """ 

629 Convert a legacy XMPP ID to a valid XMPP msg ID. 

630 Needed for read marks and message corrections. 

631 

632 The default implementation just converts the legacy ID to a :class:`str`, 

633 but this should be overridden in case some characters needs to be escaped, 

634 or to add some additional, 

635 :term:`legacy network <Legacy Network`>-specific logic. 

636 

637 The default implementation is an identity function. 

638 

639 :param i: The XMPP stanza ID 

640 :return: An ID that can be used to identify a message on the legacy network 

641 """ 

642 return cast(LegacyMessageType, i) 

643 

644 xmpp_msg_id_to_legacy_msg_id = staticmethod( 

645 deprecated("BaseSession.xmpp_msg_id_to_legacy_msg_id", xmpp_to_legacy_msg_id) 

646 ) 

647 

648 def raise_if_not_logged(self): 

649 if not self.logged: 

650 raise XMPPError( 

651 "internal-server-error", 

652 text="You are not logged to the legacy network", 

653 ) 

654 

655 @classmethod 

656 def _from_user_or_none(cls, user): 

657 if user is None: 

658 log.debug("user not found") 

659 raise XMPPError(text="User not found", condition="subscription-required") 

660 

661 session = _sessions.get(user.jid.bare) 

662 if session is None: 

663 _sessions[user.jid.bare] = session = cls(user) 

664 return session 

665 

666 @classmethod 

667 def from_user(cls, user): 

668 return cls._from_user_or_none(user) 

669 

670 @classmethod 

671 def from_stanza(cls, s) -> "BaseSession": 

672 # """ 

673 # Get a user's :class:`.LegacySession` using the "from" field of a stanza 

674 # 

675 # Meant to be called from :class:`BaseGateway` only. 

676 # 

677 # :param s: 

678 # :return: 

679 # """ 

680 return cls.from_jid(s.get_from()) 

681 

682 @classmethod 

683 def from_jid(cls, jid: JID) -> "BaseSession": 

684 # """ 

685 # Get a user's :class:`.LegacySession` using its jid 

686 # 

687 # Meant to be called from :class:`BaseGateway` only. 

688 # 

689 # :param jid: 

690 # :return: 

691 # """ 

692 session = _sessions.get(jid.bare) 

693 if session is not None: 

694 return session 

695 with cls.xmpp.store.session() as orm: 

696 user = orm.query(GatewayUser).filter_by(jid=jid.bare).one_or_none() 

697 return cls._from_user_or_none(user) 

698 

699 @classmethod 

700 async def kill_by_jid(cls, jid: JID) -> None: 

701 # """ 

702 # Terminate a user session. 

703 # 

704 # Meant to be called from :class:`BaseGateway` only. 

705 # 

706 # :param jid: 

707 # :return: 

708 # """ 

709 log.debug("Killing session of %s", jid) 

710 for user_jid, session in _sessions.items(): 

711 if user_jid == jid.bare: 

712 break 

713 else: 

714 log.debug("Did not find a session for %s", jid) 

715 return 

716 for c in session.contacts: 

717 c.unsubscribe() 

718 for m in session.bookmarks: 

719 m.shutdown() 

720 

721 try: 

722 session = _sessions.pop(jid.bare) 

723 except KeyError: 

724 log.warning("User not found during unregistration") 

725 return 

726 

727 session.cancel_all_tasks() 

728 

729 await cls.xmpp.unregister(session) 

730 with cls.xmpp.store.session() as orm: 

731 orm.delete(session.user) 

732 orm.commit() 

733 

734 def __ack(self, msg: Message) -> None: 

735 if not self.xmpp.PROPER_RECEIPTS: 

736 self.xmpp.delivery_receipt.ack(msg) 

737 

738 def send_gateway_status( 

739 self, 

740 status: str | None = None, 

741 show=PresenceShows | None, 

742 **kwargs, 

743 ) -> None: 

744 """ 

745 Send a presence from the gateway to the user. 

746 

747 Can be used to indicate the user session status, ie "SMS code required", "connected", … 

748 

749 :param status: A status message 

750 :param show: Presence stanza 'show' element. I suggest using "dnd" to show 

751 that the gateway is not fully functional 

752 """ 

753 self.__cached_presence = CachedPresence(status, show, kwargs) 

754 self.xmpp.send_presence( 

755 pto=self.user_jid.bare, pstatus=status, pshow=show, **kwargs 

756 ) 

757 

758 def send_cached_presence(self, to: JID) -> None: 

759 if not self.__cached_presence: 

760 self.xmpp.send_presence(pto=to, ptype="unavailable") 

761 return 

762 self.xmpp.send_presence( 

763 pto=to, 

764 pstatus=self.__cached_presence.status, 

765 pshow=self.__cached_presence.show, 

766 **self.__cached_presence.kwargs, 

767 ) 

768 

769 def send_gateway_message(self, text: str, **msg_kwargs) -> None: 

770 """ 

771 Send a message from the gateway component to the user. 

772 

773 Can be used to indicate the user session status, ie "SMS code required", "connected", … 

774 

775 :param text: A text 

776 """ 

777 self.xmpp.send_text(text, mto=self.user_jid, **msg_kwargs) 

778 

779 def send_gateway_invite( 

780 self, 

781 muc: LegacyMUC, 

782 reason: str | None = None, 

783 password: str | None = None, 

784 ) -> None: 

785 """ 

786 Send an invitation to join a MUC, emanating from the gateway component. 

787 

788 :param muc: 

789 :param reason: 

790 :param password: 

791 """ 

792 self.xmpp.invite_to(muc, reason=reason, password=password, mto=self.user_jid) 

793 

794 async def input(self, text: str, **msg_kwargs): 

795 """ 

796 Request user input via direct messages from the gateway component. 

797 

798 Wraps call to :meth:`.BaseSession.input` 

799 

800 :param text: The prompt to send to the user 

801 :param msg_kwargs: Extra attributes 

802 :return: 

803 """ 

804 return await self.xmpp.input(self.user_jid, text, **msg_kwargs) 

805 

806 async def send_qr(self, text: str) -> None: 

807 """ 

808 Sends a QR code generated from 'text' via HTTP Upload and send the URL to 

809 ``self.user`` 

810 

811 :param text: Text to encode as a QR code 

812 """ 

813 await self.xmpp.send_qr(text, mto=self.user_jid) 

814 

815 async def get_contact_or_group_or_participant(self, jid: JID, create: bool = True): 

816 if (contact := self.contacts.by_jid_only_if_exists(jid)) is not None: 

817 return contact 

818 if (muc := self.bookmarks.by_jid_only_if_exists(JID(jid.bare))) is not None: 

819 return await self.__get_muc_or_participant(muc, jid) 

820 else: 

821 muc = None 

822 

823 if not create: 

824 return None 

825 

826 try: 

827 return await self.contacts.by_jid(jid) 

828 except XMPPError: 

829 if muc is None: 

830 try: 

831 muc = await self.bookmarks.by_jid(jid) 

832 except XMPPError: 

833 return 

834 return await self.__get_muc_or_participant(muc, jid) 

835 

836 @staticmethod 

837 async def __get_muc_or_participant(muc: LegacyMUC, jid: JID): 

838 if nick := jid.resource: 

839 return await muc.get_participant(nick, create=False, fill_first=True) 

840 return muc 

841 

842 async def wait_for_ready(self, timeout: int | float | None = 10): 

843 # """ 

844 # Wait until session, contacts and bookmarks are ready 

845 # 

846 # (slidge internal use) 

847 # 

848 # :param timeout: 

849 # :return: 

850 # """ 

851 try: 

852 await asyncio.wait_for(asyncio.shield(self.ready), timeout) 

853 await asyncio.wait_for(asyncio.shield(self.contacts.ready), timeout) 

854 await asyncio.wait_for(asyncio.shield(self.bookmarks.ready), timeout) 

855 except TimeoutError: 

856 raise XMPPError( 

857 "recipient-unavailable", 

858 "Legacy session is not fully initialized, retry later", 

859 ) 

860 

861 def legacy_module_data_update(self, data: dict) -> None: 

862 user = self.user 

863 user.legacy_module_data.update(data) 

864 self.xmpp.store.users.update(user) 

865 

866 def legacy_module_data_set(self, data: dict) -> None: 

867 user = self.user 

868 user.legacy_module_data = data 

869 self.xmpp.store.users.update(user) 

870 

871 def legacy_module_data_clear(self) -> None: 

872 user = self.user 

873 user.legacy_module_data.clear() 

874 self.xmpp.store.users.update(user) 

875 

876 

877# keys = user.jid.bare 

878_sessions: dict[str, BaseSession] = {} 

879log = logging.getLogger(__name__)