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

261 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-03-13 22:59 +0000

1import abc 

2import asyncio 

3import logging 

4from collections.abc import Iterable 

5from typing import ( 

6 TYPE_CHECKING, 

7 Any, 

8 Generic, 

9 NamedTuple, 

10 Optional, 

11 Union, 

12 cast, 

13) 

14 

15import aiohttp 

16import sqlalchemy as sa 

17from slixmpp import JID, Message 

18from slixmpp.exceptions import XMPPError 

19from slixmpp.types import PresenceShows, ResourceDict 

20 

21from ..command import SearchResult 

22from ..contact import LegacyContact, LegacyRoster 

23from ..db.models import Contact, GatewayUser 

24from ..group.bookmarks import LegacyBookmarks 

25from ..group.room import LegacyMUC 

26from ..util import SubclassableOnce 

27from ..util.lock import NamedLockMixin 

28from ..util.types import ( 

29 LegacyGroupIdType, 

30 LegacyMessageType, 

31 LegacyThreadType, 

32 LinkPreview, 

33 Mention, 

34 PseudoPresenceShow, 

35 RecipientType, 

36 Sticker, 

37) 

38from ..util.util import deprecated, noop_coro 

39 

40if TYPE_CHECKING: 

41 from ..group.participant import LegacyParticipant 

42 from ..util.types import Sender 

43 from .gateway import BaseGateway 

44 

45 

46class CachedPresence(NamedTuple): 

47 status: str | None 

48 show: str | None 

49 kwargs: dict[str, Any] 

50 

51 

52class BaseSession( 

53 Generic[LegacyMessageType, RecipientType], 

54 NamedLockMixin, 

55 SubclassableOnce, 

56 abc.ABC, 

57): 

58 """ 

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

60 

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

62 

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

64 or upon registration for new (validated) users. 

65 

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

67 """ 

68 

69 """ 

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

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

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

73 the official client of a legacy network. 

74 """ 

75 

76 xmpp: "BaseGateway" 

77 """ 

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

79 session-specific. 

80 """ 

81 

82 MESSAGE_IDS_ARE_THREAD_IDS = False 

83 """ 

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

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

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

87 threads). 

88 """ 

89 SPECIAL_MSG_ID_PREFIX: str | None = None 

90 """ 

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

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

93 applied. 

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

95 """ 

96 

97 _roster_cls: type[LegacyRoster] 

98 _bookmarks_cls: type[LegacyBookmarks] 

99 

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

101 super().__init__() 

102 self.user = user 

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

104 

105 self.ignore_messages = set[str]() 

106 

107 self.contacts = self._roster_cls(self) 

108 self.is_logging_in = False 

109 self._logged = False 

110 self.__reset_ready() 

111 

112 self.bookmarks = self._bookmarks_cls(self) 

113 

114 self.thread_creation_lock = asyncio.Lock() 

115 

116 self.__cached_presence: CachedPresence | None = None 

117 

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

119 

120 @property 

121 def user_jid(self) -> JID: 

122 return self.user.jid 

123 

124 @property 

125 def user_pk(self) -> int: 

126 return self.user.id 

127 

128 @property 

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

130 return self.xmpp.http 

131 

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

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

134 self.__tasks.remove(fut) 

135 

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

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

138 self.__tasks.add(task) 

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

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

141 return task 

142 

143 def cancel_all_tasks(self) -> None: 

144 for task in self.__tasks: 

145 task.cancel() 

146 

147 @abc.abstractmethod 

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

149 """ 

150 Logs in the gateway user to the legacy network. 

151 

152 Triggered when the gateway start and on user registration. 

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

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

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

156 

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

158 """ 

159 raise NotImplementedError 

160 

161 async def logout(self) -> None: 

162 """ 

163 Logs out the gateway user from the legacy network. 

164 

165 Called on gateway shutdown. 

166 """ 

167 raise NotImplementedError 

168 

169 async def on_text( 

170 self, 

171 chat: RecipientType, 

172 text: str, 

173 *, 

174 reply_to_msg_id: LegacyMessageType | None = None, 

175 reply_to_fallback_text: str | None = None, 

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

177 thread: LegacyThreadType | None = None, 

178 link_previews: Iterable[LinkPreview] = (), 

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

180 ) -> LegacyMessageType | None: 

181 """ 

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

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

184 

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

186 

187 :param text: Content of the message 

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

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

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

191 another message (:xep:`0461`) 

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

193 by XMPP clients 

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

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

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

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

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

199 supports it. 

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

201 nicknames. 

202 :param thread: 

203 

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

205 as read by the user 

206 """ 

207 raise NotImplementedError 

208 

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

210 

211 async def on_file( 

212 self, 

213 chat: RecipientType, 

214 url: str, 

215 *, 

216 http_response: aiohttp.ClientResponse, 

217 reply_to_msg_id: LegacyMessageType | None = None, 

218 reply_to_fallback_text: str | None = None, 

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

220 thread: LegacyThreadType | None = None, 

221 ) -> LegacyMessageType | None: 

222 """ 

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

224 

225 :param url: URL of the file 

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

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

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

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

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

231 :param thread: 

232 

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

234 as read by the user 

235 """ 

236 raise NotImplementedError 

237 

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

239 

240 async def on_sticker( 

241 self, 

242 chat: RecipientType, 

243 sticker: Sticker, 

244 *, 

245 reply_to_msg_id: LegacyMessageType | None = None, 

246 reply_to_fallback_text: str | None = None, 

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

248 thread: LegacyThreadType | None = None, 

249 ) -> LegacyMessageType | None: 

250 """ 

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

252 

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

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

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

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

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

258 :param thread: 

259 

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

261 as read by the user 

262 """ 

263 raise NotImplementedError 

264 

265 async def on_active( 

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

267 ) -> None: 

268 """ 

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

270 

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

272 :param thread: 

273 """ 

274 raise NotImplementedError 

275 

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

277 

278 async def on_inactive( 

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

280 ) -> None: 

281 """ 

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

283 

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

285 :param thread: 

286 """ 

287 raise NotImplementedError 

288 

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

290 

291 async def on_composing( 

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

293 ) -> None: 

294 """ 

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

296 

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

298 :param thread: 

299 """ 

300 raise NotImplementedError 

301 

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

303 

304 async def on_paused( 

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

306 ) -> None: 

307 """ 

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

309 

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

311 :param thread: 

312 """ 

313 raise NotImplementedError 

314 

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

316 

317 async def on_gone( 

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

319 ) -> None: 

320 """ 

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

322 

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

324 :param thread: 

325 """ 

326 raise NotImplementedError 

327 

328 async def on_displayed( 

329 self, 

330 chat: RecipientType, 

331 legacy_msg_id: LegacyMessageType, 

332 thread: LegacyThreadType | None = None, 

333 ) -> None: 

334 """ 

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

336 

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

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

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

340 or 

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

342 

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

344 :param legacy_msg_id: Identifier of the message/ 

345 :param thread: 

346 """ 

347 raise NotImplementedError 

348 

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

350 

351 async def on_correct( 

352 self, 

353 chat: RecipientType, 

354 text: str, 

355 legacy_msg_id: LegacyMessageType, 

356 *, 

357 thread: LegacyThreadType | None = None, 

358 link_previews: Iterable[LinkPreview] = (), 

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

360 ) -> LegacyMessageType | None: 

361 """ 

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

363 

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

365 :meth:`.on_text`. 

366 

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

368 :param text: The new text 

369 :param legacy_msg_id: Identifier of the edited message 

370 :param thread: 

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

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

373 supports it. 

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

375 nicknames. 

376 """ 

377 raise NotImplementedError 

378 

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

380 

381 async def on_react( 

382 self, 

383 chat: RecipientType, 

384 legacy_msg_id: LegacyMessageType, 

385 emojis: list[str], 

386 thread: LegacyThreadType | None = None, 

387 ) -> None: 

388 """ 

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

390 

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

392 :param thread: 

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

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

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

396 """ 

397 raise NotImplementedError 

398 

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

400 

401 async def on_retract( 

402 self, 

403 chat: RecipientType, 

404 legacy_msg_id: LegacyMessageType, 

405 thread: LegacyThreadType | None = None, 

406 ) -> None: 

407 """ 

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

409 

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

411 :param thread: 

412 :param legacy_msg_id: Legacy ID of the retracted message 

413 """ 

414 raise NotImplementedError 

415 

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

417 

418 async def on_presence( 

419 self, 

420 resource: str, 

421 show: PseudoPresenceShow, 

422 status: str, 

423 resources: dict[str, ResourceDict], 

424 merged_resource: ResourceDict | None, 

425 ) -> None: 

426 """ 

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

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

429 status. 

430 

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

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

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

434 str. 

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

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

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

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

439 following rules described in :meth:`merge_resources` 

440 """ 

441 raise NotImplementedError 

442 

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

444 

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

446 """ 

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

448 

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

450 

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

452 in :attr:`.BaseGateway.SEARCH_FIELDS` 

453 :return: 

454 """ 

455 raise NotImplementedError 

456 

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

458 

459 async def on_avatar( 

460 self, 

461 bytes_: bytes | None, 

462 hash_: str | None, 

463 type_: str | None, 

464 width: int | None, 

465 height: int | None, 

466 ) -> None: 

467 """ 

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

469 

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

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

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

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

474 the avatar. 

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

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

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

478 """ 

479 raise NotImplementedError 

480 

481 async def on_moderate( 

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

483 ) -> None: 

484 """ 

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

486 a MUC using :xep:`0425`. 

487 

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

489 XMPPError with a human-readable message. 

490 

491 NB: the legacy module is responsible for calling 

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

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

494 moderation message from the MUC automatically. 

495 

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

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

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

499 user-moderator. 

500 """ 

501 raise NotImplementedError 

502 

503 async def on_create_group( 

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

505 ) -> LegacyGroupIdType: 

506 """ 

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

508 dedicated :term:`Command`. 

509 

510 :param name: Name of the group 

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

512 """ 

513 raise NotImplementedError 

514 

515 async def on_invitation( 

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

517 ) -> None: 

518 """ 

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

520 :xep:`0249`. 

521 

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

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

524 behaviour. 

525 

526 :param contact: The invitee 

527 :param muc: The group 

528 :param reason: Optionally, a reason 

529 """ 

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

531 

532 async def on_leave_group(self, muc_legacy_id: LegacyGroupIdType) -> None: 

533 """ 

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

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

536 

537 This should be interpreted as definitely leaving the group. 

538 

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

540 """ 

541 raise NotImplementedError 

542 

543 async def on_preferences( 

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

545 ) -> None: 

546 """ 

547 This is called when the user updates their preferences. 

548 

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

550 something when a preference has changed. 

551 """ 

552 raise NotImplementedError 

553 

554 def __reset_ready(self) -> None: 

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

556 

557 @property 

558 def logged(self): 

559 return self._logged 

560 

561 @logged.setter 

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

563 self.is_logging_in = False 

564 self._logged = v 

565 if self.ready.done(): 

566 if v: 

567 return 

568 self.__reset_ready() 

569 self.shutdown(logout=False) 

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

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

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

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

574 orm.commit() 

575 else: 

576 if v: 

577 self.ready.set_result(True) 

578 

579 def __repr__(self) -> str: 

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

581 

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

583 for m in self.bookmarks: 

584 m.shutdown() 

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

586 for jid in orm.execute( 

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

588 ).scalars(): 

589 pres = self.xmpp.make_presence( 

590 pfrom=jid, 

591 pto=self.user_jid, 

592 ptype="unavailable", 

593 pstatus="Gateway has shut down.", 

594 ) 

595 pres.send() 

596 if logout: 

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

598 else: 

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

600 

601 async def __logout(self) -> None: 

602 try: 

603 await self.logout() 

604 except NotImplementedError: 

605 pass 

606 except KeyboardInterrupt: 

607 pass 

608 

609 @staticmethod 

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

611 """ 

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

613 Needed for read marks, retractions and message corrections. 

614 

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

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

617 or to add some additional, 

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

619 

620 :param legacy_msg_id: 

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

622 """ 

623 return str(legacy_msg_id) 

624 

625 legacy_msg_id_to_xmpp_msg_id = staticmethod( 

626 deprecated("BaseSession.legacy_msg_id_to_xmpp_msg_id", legacy_to_xmpp_msg_id) 

627 ) 

628 

629 @staticmethod 

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

631 """ 

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

633 Needed for read marks and message corrections. 

634 

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

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

637 or to add some additional, 

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

639 

640 The default implementation is an identity function. 

641 

642 :param i: The XMPP stanza ID 

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

644 """ 

645 return cast(LegacyMessageType, i) 

646 

647 xmpp_msg_id_to_legacy_msg_id = staticmethod( 

648 deprecated("BaseSession.xmpp_msg_id_to_legacy_msg_id", xmpp_to_legacy_msg_id) 

649 ) 

650 

651 def raise_if_not_logged(self) -> None: 

652 if not self.logged: 

653 raise XMPPError( 

654 "internal-server-error", 

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

656 ) 

657 

658 @classmethod 

659 def _from_user_or_none(cls, user): 

660 if user is None: 

661 log.debug("user not found") 

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

663 

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

665 if session is None: 

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

667 return session 

668 

669 @classmethod 

670 def from_user(cls, user): 

671 return cls._from_user_or_none(user) 

672 

673 @classmethod 

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

675 # """ 

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

677 # 

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

679 # 

680 # :param s: 

681 # :return: 

682 # """ 

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

684 

685 @classmethod 

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

687 # """ 

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

689 # 

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

691 # 

692 # :param jid: 

693 # :return: 

694 # """ 

695 session = _sessions.get(jid.bare) 

696 if session is not None: 

697 return session 

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

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

700 return cls._from_user_or_none(user) 

701 

702 @classmethod 

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

704 # """ 

705 # Terminate a user session. 

706 # 

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

708 # 

709 # :param jid: 

710 # :return: 

711 # """ 

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

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

714 if user_jid == jid.bare: 

715 break 

716 else: 

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

718 return 

719 for c in session.contacts: 

720 c.unsubscribe() 

721 for m in session.bookmarks: 

722 m.shutdown() 

723 

724 try: 

725 session = _sessions.pop(jid.bare) 

726 except KeyError: 

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

728 return 

729 

730 session.cancel_all_tasks() 

731 

732 await cls.xmpp.unregister(session) 

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

734 orm.delete(session.user) 

735 orm.commit() 

736 

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

738 if not self.xmpp.PROPER_RECEIPTS: 

739 self.xmpp.delivery_receipt.ack(msg) 

740 

741 def send_gateway_status( 

742 self, 

743 status: str | None = None, 

744 show=PresenceShows | None, 

745 **kwargs, 

746 ) -> None: 

747 """ 

748 Send a presence from the gateway to the user. 

749 

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

751 

752 :param status: A status message 

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

754 that the gateway is not fully functional 

755 """ 

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

757 self.xmpp.send_presence( 

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

759 ) 

760 

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

762 if not self.__cached_presence: 

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

764 return 

765 self.xmpp.send_presence( 

766 pto=to, 

767 pstatus=self.__cached_presence.status, 

768 pshow=self.__cached_presence.show, 

769 **self.__cached_presence.kwargs, 

770 ) 

771 

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

773 """ 

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

775 

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

777 

778 :param text: A text 

779 """ 

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

781 

782 def send_gateway_invite( 

783 self, 

784 muc: LegacyMUC, 

785 reason: str | None = None, 

786 password: str | None = None, 

787 ) -> None: 

788 """ 

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

790 

791 :param muc: 

792 :param reason: 

793 :param password: 

794 """ 

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

796 

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

798 """ 

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

800 

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

802 

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

804 :param msg_kwargs: Extra attributes 

805 :return: 

806 """ 

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

808 

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

810 """ 

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

812 ``self.user`` 

813 

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

815 """ 

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

817 

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

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

820 return contact 

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

822 return await self.__get_muc_or_participant(muc, jid) 

823 else: 

824 muc = None 

825 

826 if not create: 

827 return None 

828 

829 try: 

830 return await self.contacts.by_jid(jid) 

831 except XMPPError: 

832 if muc is None: 

833 try: 

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

835 except XMPPError: 

836 return 

837 return await self.__get_muc_or_participant(muc, jid) 

838 

839 @staticmethod 

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

841 if nick := jid.resource: 

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

843 return muc 

844 

845 async def wait_for_ready(self, timeout: int | float | None = 10) -> None: 

846 # """ 

847 # Wait until session, contacts and bookmarks are ready 

848 # 

849 # (slidge internal use) 

850 # 

851 # :param timeout: 

852 # :return: 

853 # """ 

854 try: 

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

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

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

858 except TimeoutError: 

859 raise XMPPError( 

860 "recipient-unavailable", 

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

862 ) 

863 

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

865 user = self.user 

866 user.legacy_module_data.update(data) 

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

868 

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

870 user = self.user 

871 user.legacy_module_data = data 

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

873 

874 def legacy_module_data_clear(self) -> None: 

875 user = self.user 

876 user.legacy_module_data.clear() 

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

878 

879 

880# keys = user.jid.bare 

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

882log = logging.getLogger(__name__)