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

258 statements  

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

1import asyncio 

2import logging 

3from typing import ( 

4 TYPE_CHECKING, 

5 Any, 

6 Generic, 

7 Iterable, 

8 NamedTuple, 

9 Optional, 

10 Type, 

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 ABCSubclassableOnceAtMost 

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: Optional[str] 

48 show: Optional[str] 

49 kwargs: dict[str, Any] 

50 

51 

52class BaseSession( 

53 Generic[LegacyMessageType, RecipientType], 

54 NamedLockMixin, 

55 metaclass=ABCSubclassableOnceAtMost, 

56): 

57 """ 

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

59 

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

61 

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

63 or upon registration for new (validated) users. 

64 

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

66 """ 

67 

68 """ 

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

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

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

72 the official client of a legacy network. 

73 """ 

74 

75 xmpp: "BaseGateway" 

76 """ 

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

78 session-specific. 

79 """ 

80 

81 MESSAGE_IDS_ARE_THREAD_IDS = False 

82 """ 

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

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

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

86 threads). 

87 """ 

88 SPECIAL_MSG_ID_PREFIX: Optional[str] = None 

89 """ 

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

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

92 applied. 

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

94 """ 

95 

96 _roster_cls: Type[LegacyRoster] 

97 _bookmarks_cls: Type[LegacyBookmarks] 

98 

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

100 super().__init__() 

101 self.user = user 

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

103 

104 self.ignore_messages = set[str]() 

105 

106 self.contacts = self._roster_cls(self) 

107 self.is_logging_in = False 

108 self._logged = False 

109 self.__reset_ready() 

110 

111 self.bookmarks = self._bookmarks_cls(self) 

112 

113 self.thread_creation_lock = asyncio.Lock() 

114 

115 self.__cached_presence: Optional[CachedPresence] = None 

116 

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

118 

119 @property 

120 def user_jid(self) -> JID: 

121 return self.user.jid 

122 

123 @property 

124 def user_pk(self) -> int: 

125 return self.user.id 

126 

127 @property 

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

129 return self.xmpp.http 

130 

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

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

133 self.__tasks.remove(fut) 

134 

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

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

137 self.__tasks.add(task) 

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

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

140 return task 

141 

142 def cancel_all_tasks(self) -> None: 

143 for task in self.__tasks: 

144 task.cancel() 

145 

146 async def login(self) -> Optional[str]: 

147 """ 

148 Logs in the gateway user to the legacy network. 

149 

150 Triggered when the gateway start and on user registration. 

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

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

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

154 

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

156 """ 

157 raise NotImplementedError 

158 

159 async def logout(self) -> None: 

160 """ 

161 Logs out the gateway user from the legacy network. 

162 

163 Called on gateway shutdown. 

164 """ 

165 raise NotImplementedError 

166 

167 async def on_text( 

168 self, 

169 chat: RecipientType, 

170 text: str, 

171 *, 

172 reply_to_msg_id: Optional[LegacyMessageType] = None, 

173 reply_to_fallback_text: Optional[str] = None, 

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

175 thread: Optional[LegacyThreadType] = None, 

176 link_previews: Iterable[LinkPreview] = (), 

177 mentions: Optional[list[Mention]] = None, 

178 ) -> Optional[LegacyMessageType]: 

179 """ 

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

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

182 

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

184 

185 :param text: Content of the message 

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

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

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

189 another message (:xep:`0461`) 

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

191 by XMPP clients 

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

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

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

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

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

197 supports it. 

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

199 nicknames. 

200 :param thread: 

201 

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

203 as read by the user 

204 """ 

205 raise NotImplementedError 

206 

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

208 

209 async def on_file( 

210 self, 

211 chat: RecipientType, 

212 url: str, 

213 *, 

214 http_response: aiohttp.ClientResponse, 

215 reply_to_msg_id: Optional[LegacyMessageType] = None, 

216 reply_to_fallback_text: Optional[str] = None, 

217 reply_to: Optional[Union["LegacyContact", "LegacyParticipant"]] = None, 

218 thread: Optional[LegacyThreadType] = None, 

219 ) -> Optional[LegacyMessageType]: 

220 """ 

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

222 

223 :param url: URL of the file 

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

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

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

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

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

229 :param thread: 

230 

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

232 as read by the user 

233 """ 

234 raise NotImplementedError 

235 

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

237 

238 async def on_sticker( 

239 self, 

240 chat: RecipientType, 

241 sticker: Sticker, 

242 *, 

243 reply_to_msg_id: Optional[LegacyMessageType] = None, 

244 reply_to_fallback_text: Optional[str] = None, 

245 reply_to: Optional[Union["LegacyContact", "LegacyParticipant"]] = None, 

246 thread: Optional[LegacyThreadType] = None, 

247 ) -> Optional[LegacyMessageType]: 

248 """ 

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

250 

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

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

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

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

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

256 :param thread: 

257 

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

259 as read by the user 

260 """ 

261 raise NotImplementedError 

262 

263 async def on_active( 

264 self, chat: RecipientType, thread: Optional[LegacyThreadType] = None 

265 ): 

266 """ 

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

268 

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

270 :param thread: 

271 """ 

272 raise NotImplementedError 

273 

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

275 

276 async def on_inactive( 

277 self, chat: RecipientType, thread: Optional[LegacyThreadType] = None 

278 ): 

279 """ 

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

281 

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

283 :param thread: 

284 """ 

285 raise NotImplementedError 

286 

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

288 

289 async def on_composing( 

290 self, chat: RecipientType, thread: Optional[LegacyThreadType] = None 

291 ): 

292 """ 

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

294 

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

296 :param thread: 

297 """ 

298 raise NotImplementedError 

299 

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

301 

302 async def on_paused( 

303 self, chat: RecipientType, thread: Optional[LegacyThreadType] = None 

304 ): 

305 """ 

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

307 

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

309 :param thread: 

310 """ 

311 raise NotImplementedError 

312 

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

314 

315 async def on_gone( 

316 self, chat: RecipientType, thread: Optional[LegacyThreadType] = None 

317 ): 

318 """ 

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

320 

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

322 :param thread: 

323 """ 

324 raise NotImplementedError 

325 

326 async def on_displayed( 

327 self, 

328 chat: RecipientType, 

329 legacy_msg_id: LegacyMessageType, 

330 thread: Optional[LegacyThreadType] = None, 

331 ): 

332 """ 

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

334 

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

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

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

338 or 

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

340 

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

342 :param legacy_msg_id: Identifier of the message/ 

343 :param thread: 

344 """ 

345 raise NotImplementedError 

346 

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

348 

349 async def on_correct( 

350 self, 

351 chat: RecipientType, 

352 text: str, 

353 legacy_msg_id: LegacyMessageType, 

354 *, 

355 thread: Optional[LegacyThreadType] = None, 

356 link_previews: Iterable[LinkPreview] = (), 

357 mentions: Optional[list[Mention]] = None, 

358 ) -> Optional[LegacyMessageType]: 

359 """ 

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

361 

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

363 :meth:`.on_text`. 

364 

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

366 :param text: The new text 

367 :param legacy_msg_id: Identifier of the edited message 

368 :param thread: 

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

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

371 supports it. 

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

373 nicknames. 

374 """ 

375 raise NotImplementedError 

376 

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

378 

379 async def on_react( 

380 self, 

381 chat: RecipientType, 

382 legacy_msg_id: LegacyMessageType, 

383 emojis: list[str], 

384 thread: Optional[LegacyThreadType] = None, 

385 ): 

386 """ 

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

388 

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

390 :param thread: 

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

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

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

394 """ 

395 raise NotImplementedError 

396 

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

398 

399 async def on_retract( 

400 self, 

401 chat: RecipientType, 

402 legacy_msg_id: LegacyMessageType, 

403 thread: Optional[LegacyThreadType] = None, 

404 ): 

405 """ 

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

407 

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

409 :param thread: 

410 :param legacy_msg_id: Legacy ID of the retracted message 

411 """ 

412 raise NotImplementedError 

413 

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

415 

416 async def on_presence( 

417 self, 

418 resource: str, 

419 show: PseudoPresenceShow, 

420 status: str, 

421 resources: dict[str, ResourceDict], 

422 merged_resource: Optional[ResourceDict], 

423 ): 

424 """ 

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

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

427 status. 

428 

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

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

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

432 str. 

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

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

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

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

437 following rules described in :meth:`merge_resources` 

438 """ 

439 raise NotImplementedError 

440 

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

442 

443 async def on_search(self, form_values: dict[str, str]) -> Optional[SearchResult]: 

444 """ 

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

446 

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

448 

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

450 in :attr:`.BaseGateway.SEARCH_FIELDS` 

451 :return: 

452 """ 

453 raise NotImplementedError 

454 

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

456 

457 async def on_avatar( 

458 self, 

459 bytes_: Optional[bytes], 

460 hash_: Optional[str], 

461 type_: Optional[str], 

462 width: Optional[int], 

463 height: Optional[int], 

464 ) -> None: 

465 """ 

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

467 

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

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

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

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

472 the avatar. 

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

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

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

476 """ 

477 raise NotImplementedError 

478 

479 async def on_moderate( 

480 self, muc: LegacyMUC, legacy_msg_id: LegacyMessageType, reason: Optional[str] 

481 ): 

482 """ 

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

484 a MUC using :xep:`0425`. 

485 

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

487 XMPPError with a human-readable message. 

488 

489 NB: the legacy module is responsible for calling 

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

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

492 moderation message from the MUC automatically. 

493 

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

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

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

497 user-moderator. 

498 """ 

499 raise NotImplementedError 

500 

501 async def on_create_group( 

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

503 ) -> LegacyGroupIdType: 

504 """ 

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

506 dedicated :term:`Command`. 

507 

508 :param name: Name of the group 

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

510 """ 

511 raise NotImplementedError 

512 

513 async def on_invitation( 

514 self, contact: LegacyContact, muc: LegacyMUC, reason: Optional[str] 

515 ) -> None: 

516 """ 

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

518 :xep:`0249`. 

519 

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

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

522 behaviour. 

523 

524 :param contact: The invitee 

525 :param muc: The group 

526 :param reason: Optionally, a reason 

527 """ 

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

529 

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

531 """ 

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

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

534 

535 This should be interpreted as definitely leaving the group. 

536 

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

538 """ 

539 raise NotImplementedError 

540 

541 async def on_preferences( 

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

543 ) -> None: 

544 """ 

545 This is called when the user updates their preferences. 

546 

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

548 something when a preference has changed. 

549 """ 

550 raise NotImplementedError 

551 

552 def __reset_ready(self) -> None: 

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

554 

555 @property 

556 def logged(self): 

557 return self._logged 

558 

559 @logged.setter 

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

561 self.is_logging_in = False 

562 self._logged = v 

563 if self.ready.done(): 

564 if v: 

565 return 

566 self.__reset_ready() 

567 self.shutdown(logout=False) 

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

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

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

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

572 orm.commit() 

573 else: 

574 if v: 

575 self.ready.set_result(True) 

576 

577 def __repr__(self) -> str: 

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

579 

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

581 for m in self.bookmarks: 

582 m.shutdown() 

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

584 for jid in orm.execute( 

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

586 ).scalars(): 

587 pres = self.xmpp.make_presence( 

588 pfrom=jid, 

589 pto=self.user_jid, 

590 ptype="unavailable", 

591 pstatus="Gateway has shut down.", 

592 ) 

593 pres.send() 

594 if logout: 

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

596 else: 

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

598 

599 async def __logout(self) -> None: 

600 try: 

601 await self.logout() 

602 except NotImplementedError: 

603 pass 

604 except KeyboardInterrupt: 

605 pass 

606 

607 @staticmethod 

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

609 """ 

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

611 Needed for read marks, retractions and message corrections. 

612 

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

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

615 or to add some additional, 

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

617 

618 :param legacy_msg_id: 

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

620 """ 

621 return str(legacy_msg_id) 

622 

623 legacy_msg_id_to_xmpp_msg_id = staticmethod( 

624 deprecated("BaseSession.legacy_msg_id_to_xmpp_msg_id", legacy_to_xmpp_msg_id) 

625 ) 

626 

627 @staticmethod 

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

629 """ 

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

631 Needed for read marks and message corrections. 

632 

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

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

635 or to add some additional, 

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

637 

638 The default implementation is an identity function. 

639 

640 :param i: The XMPP stanza ID 

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

642 """ 

643 return cast(LegacyMessageType, i) 

644 

645 xmpp_msg_id_to_legacy_msg_id = staticmethod( 

646 deprecated("BaseSession.xmpp_msg_id_to_legacy_msg_id", xmpp_to_legacy_msg_id) 

647 ) 

648 

649 def raise_if_not_logged(self): 

650 if not self.logged: 

651 raise XMPPError( 

652 "internal-server-error", 

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

654 ) 

655 

656 @classmethod 

657 def _from_user_or_none(cls, user): 

658 if user is None: 

659 log.debug("user not found") 

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

661 

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

663 if session is None: 

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

665 return session 

666 

667 @classmethod 

668 def from_user(cls, user): 

669 return cls._from_user_or_none(user) 

670 

671 @classmethod 

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

673 # """ 

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

675 # 

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

677 # 

678 # :param s: 

679 # :return: 

680 # """ 

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

682 

683 @classmethod 

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

685 # """ 

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

687 # 

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

689 # 

690 # :param jid: 

691 # :return: 

692 # """ 

693 session = _sessions.get(jid.bare) 

694 if session is not None: 

695 return session 

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

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

698 return cls._from_user_or_none(user) 

699 

700 @classmethod 

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

702 # """ 

703 # Terminate a user session. 

704 # 

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

706 # 

707 # :param jid: 

708 # :return: 

709 # """ 

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

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

712 if user_jid == jid.bare: 

713 break 

714 else: 

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

716 return 

717 for c in session.contacts: 

718 c.unsubscribe() 

719 for m in session.bookmarks: 

720 m.shutdown() 

721 

722 try: 

723 session = _sessions.pop(jid.bare) 

724 except KeyError: 

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

726 return 

727 

728 session.cancel_all_tasks() 

729 

730 await cls.xmpp.unregister(session) 

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

732 orm.delete(session.user) 

733 orm.commit() 

734 

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

736 if not self.xmpp.PROPER_RECEIPTS: 

737 self.xmpp.delivery_receipt.ack(msg) 

738 

739 def send_gateway_status( 

740 self, 

741 status: Optional[str] = None, 

742 show=Optional[PresenceShows], 

743 **kwargs, 

744 ) -> None: 

745 """ 

746 Send a presence from the gateway to the user. 

747 

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

749 

750 :param status: A status message 

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

752 that the gateway is not fully functional 

753 """ 

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

755 self.xmpp.send_presence( 

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

757 ) 

758 

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

760 if not self.__cached_presence: 

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

762 return 

763 self.xmpp.send_presence( 

764 pto=to, 

765 pstatus=self.__cached_presence.status, 

766 pshow=self.__cached_presence.show, 

767 **self.__cached_presence.kwargs, 

768 ) 

769 

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

771 """ 

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

773 

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

775 

776 :param text: A text 

777 """ 

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

779 

780 def send_gateway_invite( 

781 self, 

782 muc: LegacyMUC, 

783 reason: Optional[str] = None, 

784 password: Optional[str] = None, 

785 ) -> None: 

786 """ 

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

788 

789 :param muc: 

790 :param reason: 

791 :param password: 

792 """ 

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

794 

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

796 """ 

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

798 

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

800 

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

802 :param msg_kwargs: Extra attributes 

803 :return: 

804 """ 

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

806 

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

808 """ 

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

810 ``self.user`` 

811 

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

813 """ 

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

815 

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

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

818 return contact 

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

820 return await self.__get_muc_or_participant(muc, jid) 

821 else: 

822 muc = None 

823 

824 if not create: 

825 return None 

826 

827 try: 

828 return await self.contacts.by_jid(jid) 

829 except XMPPError: 

830 if muc is None: 

831 try: 

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

833 except XMPPError: 

834 return 

835 return await self.__get_muc_or_participant(muc, jid) 

836 

837 @staticmethod 

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

839 if nick := jid.resource: 

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

841 return muc 

842 

843 async def wait_for_ready(self, timeout: Optional[Union[int, float]] = 10): 

844 # """ 

845 # Wait until session, contacts and bookmarks are ready 

846 # 

847 # (slidge internal use) 

848 # 

849 # :param timeout: 

850 # :return: 

851 # """ 

852 try: 

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

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

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

856 except asyncio.TimeoutError: 

857 raise XMPPError( 

858 "recipient-unavailable", 

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

860 ) 

861 

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

863 user = self.user 

864 user.legacy_module_data.update(data) 

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

866 

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

868 user = self.user 

869 user.legacy_module_data = data 

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

871 

872 def legacy_module_data_clear(self) -> None: 

873 user = self.user 

874 user.legacy_module_data.clear() 

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

876 

877 

878# keys = user.jid.bare 

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

880log = logging.getLogger(__name__)