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

256 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-26 19:34 +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 

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 ResourceDict, 

37 Sticker, 

38) 

39from ..util.util import deprecated, noop_coro 

40 

41if TYPE_CHECKING: 

42 from ..group.participant import LegacyParticipant 

43 from ..util.types import Sender 

44 from .gateway import BaseGateway 

45 

46 

47class CachedPresence(NamedTuple): 

48 status: Optional[str] 

49 show: Optional[str] 

50 kwargs: dict[str, Any] 

51 

52 

53class BaseSession( 

54 Generic[LegacyMessageType, RecipientType], 

55 NamedLockMixin, 

56 metaclass=ABCSubclassableOnceAtMost, 

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: Optional[str] = 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: Optional[CachedPresence] = 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 async def login(self) -> Optional[str]: 

148 """ 

149 Logs in the gateway user to the legacy network. 

150 

151 Triggered when the gateway start and on user registration. 

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

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

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

155 

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

157 """ 

158 raise NotImplementedError 

159 

160 async def logout(self) -> None: 

161 """ 

162 Logs out the gateway user from the legacy network. 

163 

164 Called on gateway shutdown. 

165 """ 

166 raise NotImplementedError 

167 

168 async def on_text( 

169 self, 

170 chat: RecipientType, 

171 text: str, 

172 *, 

173 reply_to_msg_id: Optional[LegacyMessageType] = None, 

174 reply_to_fallback_text: Optional[str] = None, 

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

176 thread: Optional[LegacyThreadType] = None, 

177 link_previews: Iterable[LinkPreview] = (), 

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

179 ) -> Optional[LegacyMessageType]: 

180 """ 

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

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

183 

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

185 

186 :param text: Content of the message 

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

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

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

190 another message (:xep:`0461`) 

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

192 by XMPP clients 

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

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

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

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

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

198 supports it. 

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

200 nicknames. 

201 :param thread: 

202 

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

204 as read by the user 

205 """ 

206 raise NotImplementedError 

207 

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

209 

210 async def on_file( 

211 self, 

212 chat: RecipientType, 

213 url: str, 

214 *, 

215 http_response: aiohttp.ClientResponse, 

216 reply_to_msg_id: Optional[LegacyMessageType] = None, 

217 reply_to_fallback_text: Optional[str] = None, 

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

219 thread: Optional[LegacyThreadType] = None, 

220 ) -> Optional[LegacyMessageType]: 

221 """ 

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

223 

224 :param url: URL of the file 

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

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

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

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

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

230 :param thread: 

231 

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

233 as read by the user 

234 """ 

235 raise NotImplementedError 

236 

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

238 

239 async def on_sticker( 

240 self, 

241 chat: RecipientType, 

242 sticker: Sticker, 

243 *, 

244 reply_to_msg_id: Optional[LegacyMessageType] = None, 

245 reply_to_fallback_text: Optional[str] = None, 

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

247 thread: Optional[LegacyThreadType] = None, 

248 ) -> Optional[LegacyMessageType]: 

249 """ 

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

251 

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

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

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

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

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

257 :param thread: 

258 

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

260 as read by the user 

261 """ 

262 raise NotImplementedError 

263 

264 async def on_active( 

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

266 ): 

267 """ 

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

269 

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

271 :param thread: 

272 """ 

273 raise NotImplementedError 

274 

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

276 

277 async def on_inactive( 

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

279 ): 

280 """ 

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

282 

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

284 :param thread: 

285 """ 

286 raise NotImplementedError 

287 

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

289 

290 async def on_composing( 

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

292 ): 

293 """ 

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

295 

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

297 :param thread: 

298 """ 

299 raise NotImplementedError 

300 

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

302 

303 async def on_paused( 

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

305 ): 

306 """ 

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

308 

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

310 :param thread: 

311 """ 

312 raise NotImplementedError 

313 

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

315 

316 async def on_gone( 

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

318 ): 

319 """ 

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

321 

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

323 :param thread: 

324 """ 

325 raise NotImplementedError 

326 

327 async def on_displayed( 

328 self, 

329 chat: RecipientType, 

330 legacy_msg_id: LegacyMessageType, 

331 thread: Optional[LegacyThreadType] = None, 

332 ): 

333 """ 

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

335 

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

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

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

339 or 

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

341 

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

343 :param legacy_msg_id: Identifier of the message/ 

344 :param thread: 

345 """ 

346 raise NotImplementedError 

347 

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

349 

350 async def on_correct( 

351 self, 

352 chat: RecipientType, 

353 text: str, 

354 legacy_msg_id: LegacyMessageType, 

355 *, 

356 thread: Optional[LegacyThreadType] = None, 

357 link_previews: Iterable[LinkPreview] = (), 

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

359 ) -> Optional[LegacyMessageType]: 

360 """ 

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

362 

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

364 :meth:`.on_text`. 

365 

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

367 :param text: The new text 

368 :param legacy_msg_id: Identifier of the edited message 

369 :param thread: 

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

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

372 supports it. 

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

374 nicknames. 

375 """ 

376 raise NotImplementedError 

377 

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

379 

380 async def on_react( 

381 self, 

382 chat: RecipientType, 

383 legacy_msg_id: LegacyMessageType, 

384 emojis: list[str], 

385 thread: Optional[LegacyThreadType] = None, 

386 ): 

387 """ 

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

389 

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

391 :param thread: 

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

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

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

395 """ 

396 raise NotImplementedError 

397 

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

399 

400 async def on_retract( 

401 self, 

402 chat: RecipientType, 

403 legacy_msg_id: LegacyMessageType, 

404 thread: Optional[LegacyThreadType] = None, 

405 ): 

406 """ 

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

408 

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

410 :param thread: 

411 :param legacy_msg_id: Legacy ID of the retracted message 

412 """ 

413 raise NotImplementedError 

414 

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

416 

417 async def on_presence( 

418 self, 

419 resource: str, 

420 show: PseudoPresenceShow, 

421 status: str, 

422 resources: dict[str, ResourceDict], 

423 merged_resource: Optional[ResourceDict], 

424 ): 

425 """ 

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

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

428 status. 

429 

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

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

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

433 str. 

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

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

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

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

438 following rules described in :meth:`merge_resources` 

439 """ 

440 raise NotImplementedError 

441 

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

443 

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

445 """ 

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

447 

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

449 

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

451 in :attr:`.BaseGateway.SEARCH_FIELDS` 

452 :return: 

453 """ 

454 raise NotImplementedError 

455 

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

457 

458 async def on_avatar( 

459 self, 

460 bytes_: Optional[bytes], 

461 hash_: Optional[str], 

462 type_: Optional[str], 

463 width: Optional[int], 

464 height: Optional[int], 

465 ) -> None: 

466 """ 

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

468 

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

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

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

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

473 the avatar. 

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

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

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

477 """ 

478 raise NotImplementedError 

479 

480 async def on_moderate( 

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

482 ): 

483 """ 

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

485 a MUC using :xep:`0425`. 

486 

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

488 XMPPError with a human-readable message. 

489 

490 NB: the legacy module is responsible for calling 

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

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

493 moderation message from the MUC automatically. 

494 

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

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

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

498 user-moderator. 

499 """ 

500 raise NotImplementedError 

501 

502 async def on_create_group( 

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

504 ) -> LegacyGroupIdType: 

505 """ 

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

507 dedicated :term:`Command`. 

508 

509 :param name: Name of the group 

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

511 """ 

512 raise NotImplementedError 

513 

514 async def on_invitation( 

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

516 ) -> None: 

517 """ 

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

519 :xep:`0249`. 

520 

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

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

523 behaviour. 

524 

525 :param contact: The invitee 

526 :param muc: The group 

527 :param reason: Optionally, a reason 

528 """ 

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

530 

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

532 """ 

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

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

535 

536 This should be interpreted as definitely leaving the group. 

537 

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

539 """ 

540 raise NotImplementedError 

541 

542 async def on_preferences( 

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

544 ) -> None: 

545 """ 

546 This is called when the user updates their preferences. 

547 

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

549 something when a preference has changed. 

550 """ 

551 raise NotImplementedError 

552 

553 def __reset_ready(self) -> None: 

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

555 

556 @property 

557 def logged(self): 

558 return self._logged 

559 

560 @logged.setter 

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

562 self.is_logging_in = False 

563 self._logged = v 

564 if self.ready.done(): 

565 if v: 

566 return 

567 self.__reset_ready() 

568 self.shutdown(logout=False) 

569 else: 

570 if v: 

571 self.ready.set_result(True) 

572 

573 def __repr__(self) -> str: 

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

575 

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

577 for m in self.bookmarks: 

578 m.shutdown() 

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

580 for jid in orm.execute( 

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

582 ).scalars(): 

583 pres = self.xmpp.make_presence( 

584 pfrom=jid, 

585 pto=self.user_jid, 

586 ptype="unavailable", 

587 pstatus="Gateway has shut down.", 

588 ) 

589 pres.send() 

590 if logout: 

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

592 else: 

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

594 

595 async def __logout(self) -> None: 

596 try: 

597 await self.logout() 

598 except NotImplementedError: 

599 pass 

600 

601 @staticmethod 

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

603 """ 

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

605 Needed for read marks, retractions and message corrections. 

606 

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

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

609 or to add some additional, 

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

611 

612 :param legacy_msg_id: 

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

614 """ 

615 return str(legacy_msg_id) 

616 

617 legacy_msg_id_to_xmpp_msg_id = staticmethod( 

618 deprecated("BaseSession.legacy_msg_id_to_xmpp_msg_id", legacy_to_xmpp_msg_id) 

619 ) 

620 

621 @staticmethod 

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

623 """ 

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

625 Needed for read marks and message corrections. 

626 

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

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

629 or to add some additional, 

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

631 

632 The default implementation is an identity function. 

633 

634 :param i: The XMPP stanza ID 

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

636 """ 

637 return cast(LegacyMessageType, i) 

638 

639 xmpp_msg_id_to_legacy_msg_id = staticmethod( 

640 deprecated("BaseSession.xmpp_msg_id_to_legacy_msg_id", xmpp_to_legacy_msg_id) 

641 ) 

642 

643 def raise_if_not_logged(self): 

644 if not self.logged: 

645 raise XMPPError( 

646 "internal-server-error", 

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

648 ) 

649 

650 @classmethod 

651 def _from_user_or_none(cls, user): 

652 if user is None: 

653 log.debug("user not found") 

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

655 

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

657 if session is None: 

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

659 return session 

660 

661 @classmethod 

662 def from_user(cls, user): 

663 return cls._from_user_or_none(user) 

664 

665 @classmethod 

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

667 # """ 

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

669 # 

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

671 # 

672 # :param s: 

673 # :return: 

674 # """ 

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

676 

677 @classmethod 

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

679 # """ 

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

681 # 

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

683 # 

684 # :param jid: 

685 # :return: 

686 # """ 

687 session = _sessions.get(jid.bare) 

688 if session is not None: 

689 return session 

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

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

692 return cls._from_user_or_none(user) 

693 

694 @classmethod 

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

696 # """ 

697 # Terminate a user session. 

698 # 

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

700 # 

701 # :param jid: 

702 # :return: 

703 # """ 

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

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

706 if user_jid == jid.bare: 

707 break 

708 else: 

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

710 return 

711 for c in session.contacts: 

712 c.unsubscribe() 

713 for m in session.bookmarks: 

714 m.shutdown() 

715 

716 try: 

717 session = _sessions.pop(jid.bare) 

718 except KeyError: 

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

720 return 

721 

722 session.cancel_all_tasks() 

723 

724 await cls.xmpp.unregister(session) 

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

726 orm.delete(session.user) 

727 orm.commit() 

728 

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

730 if not self.xmpp.PROPER_RECEIPTS: 

731 self.xmpp.delivery_receipt.ack(msg) 

732 

733 def send_gateway_status( 

734 self, 

735 status: Optional[str] = None, 

736 show=Optional[PresenceShows], 

737 **kwargs, 

738 ) -> None: 

739 """ 

740 Send a presence from the gateway to the user. 

741 

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

743 

744 :param status: A status message 

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

746 that the gateway is not fully functional 

747 """ 

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

749 self.xmpp.send_presence( 

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

751 ) 

752 

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

754 if not self.__cached_presence: 

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

756 return 

757 self.xmpp.send_presence( 

758 pto=to, 

759 pstatus=self.__cached_presence.status, 

760 pshow=self.__cached_presence.show, 

761 **self.__cached_presence.kwargs, 

762 ) 

763 

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

765 """ 

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

767 

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

769 

770 :param text: A text 

771 """ 

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

773 

774 def send_gateway_invite( 

775 self, 

776 muc: LegacyMUC, 

777 reason: Optional[str] = None, 

778 password: Optional[str] = None, 

779 ) -> None: 

780 """ 

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

782 

783 :param muc: 

784 :param reason: 

785 :param password: 

786 """ 

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

788 

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

790 """ 

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

792 

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

794 

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

796 :param msg_kwargs: Extra attributes 

797 :return: 

798 """ 

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

800 

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

802 """ 

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

804 ``self.user`` 

805 

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

807 """ 

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

809 

810 def re_login(self) -> None: 

811 # Logout then re-login 

812 # 

813 # No reason to override this 

814 self.xmpp.re_login(self) 

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 try: 

841 return await muc.get_participant( 

842 nick, raise_if_not_found=True, fill_first=True 

843 ) 

844 except XMPPError: 

845 return None 

846 return muc 

847 

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

849 # """ 

850 # Wait until session, contacts and bookmarks are ready 

851 # 

852 # (slidge internal use) 

853 # 

854 # :param timeout: 

855 # :return: 

856 # """ 

857 try: 

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

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

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

861 except asyncio.TimeoutError: 

862 raise XMPPError( 

863 "recipient-unavailable", 

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

865 ) 

866 

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

868 user = self.user 

869 user.legacy_module_data.update(data) 

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

871 

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

873 user = self.user 

874 user.legacy_module_data = data 

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

876 

877 def legacy_module_data_clear(self) -> None: 

878 user = self.user 

879 user.legacy_module_data.clear() 

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

881 

882 

883# keys = user.jid.bare 

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

885log = logging.getLogger(__name__)