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

265 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-20 19:56 +0000

1import abc 

2import asyncio 

3import logging 

4from asyncio.tasks import Task 

5from collections.abc import Coroutine, Iterable 

6from typing import ( 

7 TYPE_CHECKING, 

8 Any, 

9 Generic, 

10 NamedTuple, 

11 Optional, 

12 Self, 

13 Union, 

14 cast, 

15) 

16 

17import aiohttp 

18import sqlalchemy as sa 

19from slixmpp import JID, Iq, Message, Presence 

20from slixmpp.exceptions import XMPPError 

21from slixmpp.types import PresenceShows, ResourceDict 

22 

23from slidge.db.meta import JSONSerializable 

24 

25from ..command import SearchResult 

26from ..contact import LegacyContact 

27from ..db.models import Contact, GatewayUser 

28from ..group.room import LegacyMUC 

29from ..util import SubclassableOnce 

30from ..util.lock import NamedLockMixin 

31from ..util.types import ( 

32 AnyBookmarks, 

33 AnyContact, 

34 AnyMUC, 

35 AnyRoster, 

36 AnySession, 

37 LegacyGroupIdType, 

38 LegacyMessageType, 

39 LegacyThreadType, 

40 LinkPreview, 

41 Mention, 

42 PseudoPresenceShow, 

43 RecipientType, 

44 Sticker, 

45) 

46from ..util.util import deprecated, noop_coro 

47 

48if TYPE_CHECKING: 

49 from ..group.participant import LegacyParticipant 

50 from ..util.types import AnyGateway, Sender 

51 

52 

53class CachedPresence(NamedTuple): 

54 status: str | None 

55 show: str | None 

56 kwargs: dict[str, Any] 

57 

58 

59class BaseSession( 

60 Generic[LegacyMessageType, RecipientType], 

61 NamedLockMixin, 

62 SubclassableOnce, 

63 abc.ABC, 

64): 

65 """ 

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

67 

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

69 

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

71 or upon registration for new (validated) users. 

72 

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

74 """ 

75 

76 """ 

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

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

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

80 the official client of a legacy network. 

81 """ 

82 

83 xmpp: "AnyGateway" 

84 """ 

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

86 session-specific. 

87 """ 

88 

89 MESSAGE_IDS_ARE_THREAD_IDS = False 

90 """ 

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

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

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

94 threads). 

95 """ 

96 SPECIAL_MSG_ID_PREFIX: str | None = None 

97 """ 

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

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

100 applied. 

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

102 """ 

103 

104 _roster_cls: type[AnyRoster] 

105 _bookmarks_cls: type[AnyBookmarks] 

106 

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

108 super().__init__() 

109 self.user = user 

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

111 

112 self.ignore_messages = set[str]() 

113 

114 self.contacts: AnyRoster = self._roster_cls(self) 

115 self.is_logging_in = False 

116 self._logged = False 

117 self.__reset_ready() 

118 

119 self.bookmarks = self._bookmarks_cls(self) 

120 

121 self.thread_creation_lock = asyncio.Lock() 

122 

123 self.__cached_presence: CachedPresence | None = None 

124 

125 self.__tasks = set[asyncio.Task[Any]]() 

126 

127 @property 

128 def user_jid(self) -> JID: 

129 return self.user.jid 

130 

131 @property 

132 def user_pk(self) -> int: 

133 return self.user.id 

134 

135 @property 

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

137 return self.xmpp.http 

138 

139 def __remove_task(self, fut: Task[Any]) -> None: 

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

141 self.__tasks.remove(fut) 

142 

143 def create_task( 

144 self, coro: Coroutine[Any, Any, Any], name: str | None = None 

145 ) -> asyncio.Task[Any]: 

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

147 self.__tasks.add(task) 

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

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

150 return task 

151 

152 def cancel_all_tasks(self) -> None: 

153 for task in self.__tasks: 

154 task.cancel() 

155 

156 @abc.abstractmethod 

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

158 """ 

159 Logs in the gateway user to the legacy network. 

160 

161 Triggered when the gateway start and on user registration. 

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

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

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

165 

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

167 """ 

168 raise NotImplementedError 

169 

170 async def logout(self) -> None: 

171 """ 

172 Logs out the gateway user from the legacy network. 

173 

174 Called on gateway shutdown. 

175 """ 

176 raise NotImplementedError 

177 

178 async def on_text( 

179 self, 

180 chat: RecipientType, 

181 text: str, 

182 *, 

183 reply_to_msg_id: LegacyMessageType | None = None, 

184 reply_to_fallback_text: str | None = None, 

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

186 thread: LegacyThreadType | None = None, 

187 link_previews: Iterable[LinkPreview] = (), 

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

189 ) -> LegacyMessageType | None: 

190 """ 

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

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

193 

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

195 

196 :param text: Content of the message 

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

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

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

200 another message (:xep:`0461`) 

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

202 by XMPP clients 

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

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

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

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

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

208 supports it. 

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

210 nicknames. 

211 :param thread: 

212 

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

214 as read by the user 

215 """ 

216 raise NotImplementedError 

217 

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

219 

220 async def on_file( 

221 self, 

222 chat: RecipientType, 

223 url: str, 

224 *, 

225 http_response: aiohttp.ClientResponse, 

226 reply_to_msg_id: LegacyMessageType | None = None, 

227 reply_to_fallback_text: str | None = None, 

228 reply_to: Union["AnyContact", "LegacyParticipant"] | None = None, 

229 thread: LegacyThreadType | None = None, 

230 ) -> LegacyMessageType | None: 

231 """ 

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

233 

234 :param url: URL of the file 

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

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

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

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

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

240 :param thread: 

241 

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

243 as read by the user 

244 """ 

245 raise NotImplementedError 

246 

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

248 

249 async def on_sticker( 

250 self, 

251 chat: RecipientType, 

252 sticker: Sticker, 

253 *, 

254 reply_to_msg_id: LegacyMessageType | None = None, 

255 reply_to_fallback_text: str | None = None, 

256 reply_to: Union["AnyContact", "LegacyParticipant"] | None = None, 

257 thread: LegacyThreadType | None = None, 

258 ) -> LegacyMessageType | None: 

259 """ 

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

261 

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

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

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

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

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

267 :param thread: 

268 

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

270 as read by the user 

271 """ 

272 raise NotImplementedError 

273 

274 async def on_active( 

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

276 ) -> None: 

277 """ 

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

279 

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

281 :param thread: 

282 """ 

283 raise NotImplementedError 

284 

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

286 

287 async def on_inactive( 

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

289 ) -> None: 

290 """ 

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

292 

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

294 :param thread: 

295 """ 

296 raise NotImplementedError 

297 

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

299 

300 async def on_composing( 

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

302 ) -> None: 

303 """ 

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

305 

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

307 :param thread: 

308 """ 

309 raise NotImplementedError 

310 

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

312 

313 async def on_paused( 

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

315 ) -> None: 

316 """ 

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

318 

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

320 :param thread: 

321 """ 

322 raise NotImplementedError 

323 

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

325 

326 async def on_gone( 

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

328 ) -> None: 

329 """ 

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

331 

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

333 :param thread: 

334 """ 

335 raise NotImplementedError 

336 

337 async def on_displayed( 

338 self, 

339 chat: RecipientType, 

340 legacy_msg_id: LegacyMessageType, 

341 thread: LegacyThreadType | None = None, 

342 ) -> None: 

343 """ 

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

345 

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

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

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

349 or 

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

351 

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

353 :param legacy_msg_id: Identifier of the message/ 

354 :param thread: 

355 """ 

356 raise NotImplementedError 

357 

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

359 

360 async def on_correct( 

361 self, 

362 chat: RecipientType, 

363 text: str, 

364 legacy_msg_id: LegacyMessageType, 

365 *, 

366 thread: LegacyThreadType | None = None, 

367 link_previews: Iterable[LinkPreview] = (), 

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

369 ) -> LegacyMessageType | None: 

370 """ 

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

372 

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

374 :meth:`.on_text`. 

375 

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

377 :param text: The new text 

378 :param legacy_msg_id: Identifier of the edited message 

379 :param thread: 

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

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

382 supports it. 

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

384 nicknames. 

385 """ 

386 raise NotImplementedError 

387 

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

389 

390 async def on_react( 

391 self, 

392 chat: RecipientType, 

393 legacy_msg_id: LegacyMessageType, 

394 emojis: list[str], 

395 thread: LegacyThreadType | None = None, 

396 ) -> None: 

397 """ 

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

399 

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

401 :param thread: 

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

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

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

405 """ 

406 raise NotImplementedError 

407 

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

409 

410 async def on_retract( 

411 self, 

412 chat: RecipientType, 

413 legacy_msg_id: LegacyMessageType, 

414 thread: LegacyThreadType | None = None, 

415 ) -> None: 

416 """ 

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

418 

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

420 :param thread: 

421 :param legacy_msg_id: Legacy ID of the retracted message 

422 """ 

423 raise NotImplementedError 

424 

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

426 

427 async def on_presence( 

428 self, 

429 resource: str, 

430 show: PseudoPresenceShow, 

431 status: str, 

432 resources: dict[str, ResourceDict], 

433 merged_resource: ResourceDict | None, 

434 ) -> None: 

435 """ 

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

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

438 status. 

439 

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

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

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

443 str. 

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

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

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

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

448 following rules described in :meth:`merge_resources` 

449 """ 

450 raise NotImplementedError 

451 

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

453 

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

455 """ 

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

457 

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

459 

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

461 in :attr:`.BaseGateway.SEARCH_FIELDS` 

462 :return: 

463 """ 

464 raise NotImplementedError 

465 

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

467 

468 async def on_avatar( 

469 self, 

470 bytes_: bytes | None, 

471 hash_: str | None, 

472 type_: str | None, 

473 width: int | None, 

474 height: int | None, 

475 ) -> None: 

476 """ 

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

478 

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

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

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

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

483 the avatar. 

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

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

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

487 """ 

488 raise NotImplementedError 

489 

490 async def on_moderate( 

491 self, muc: AnyMUC, legacy_msg_id: LegacyMessageType, reason: str | None 

492 ) -> None: 

493 """ 

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

495 a MUC using :xep:`0425`. 

496 

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

498 XMPPError with a human-readable message. 

499 

500 NB: the legacy module is responsible for calling 

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

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

503 moderation message from the MUC automatically. 

504 

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

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

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

508 user-moderator. 

509 """ 

510 raise NotImplementedError 

511 

512 async def on_create_group( 

513 self, name: str, contacts: list[AnyContact] 

514 ) -> LegacyGroupIdType: 

515 """ 

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

517 dedicated :term:`Command`. 

518 

519 :param name: Name of the group 

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

521 """ 

522 raise NotImplementedError 

523 

524 async def on_invitation( 

525 self, contact: AnyContact, muc: AnyMUC, reason: str | None 

526 ) -> None: 

527 """ 

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

529 :xep:`0249`. 

530 

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

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

533 behaviour. 

534 

535 :param contact: The invitee 

536 :param muc: The group 

537 :param reason: Optionally, a reason 

538 """ 

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

540 

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

542 """ 

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

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

545 

546 This should be interpreted as definitely leaving the group. 

547 

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

549 """ 

550 raise NotImplementedError 

551 

552 async def on_leave_space(self, space_legacy_id: LegacyGroupIdType) -> None: 

553 """ 

554 Triggered when the user sends a request to leave a :xep:`0503` space. 

555 

556 :param space_legacy_id: The legacy ID of the space to leave 

557 """ 

558 raise NotImplementedError 

559 

560 async def on_preferences( 

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

562 ) -> None: 

563 """ 

564 This is called when the user updates their preferences. 

565 

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

567 something when a preference has changed. 

568 """ 

569 raise NotImplementedError 

570 

571 def __reset_ready(self) -> None: 

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

573 

574 @property 

575 def logged(self) -> bool: 

576 return self._logged 

577 

578 @logged.setter 

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

580 self.is_logging_in = False 

581 self._logged = v 

582 if self.ready.done(): 

583 if v: 

584 return 

585 self.__reset_ready() 

586 self.shutdown(logout=False) 

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

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

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

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

591 orm.commit() 

592 else: 

593 if v: 

594 self.ready.set_result(True) 

595 

596 def __repr__(self) -> str: 

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

598 

599 def shutdown(self, logout: bool = True) -> asyncio.Task[None]: 

600 for m in self.bookmarks: 

601 m.shutdown() 

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

603 for jid in orm.execute( 

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

605 ).scalars(): 

606 pres = self.xmpp.make_presence( 

607 pfrom=jid, 

608 pto=self.user_jid, 

609 ptype="unavailable", 

610 pstatus="Gateway has shut down.", 

611 ) 

612 pres.send() 

613 if logout: 

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

615 else: 

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

617 

618 async def __logout(self) -> None: 

619 try: 

620 await self.logout() 

621 except NotImplementedError: 

622 pass 

623 except KeyboardInterrupt: 

624 pass 

625 

626 @staticmethod 

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

628 """ 

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

630 Needed for read marks, retractions 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 :param legacy_msg_id: 

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

639 """ 

640 return str(legacy_msg_id) 

641 

642 legacy_msg_id_to_xmpp_msg_id = staticmethod( 

643 deprecated("BaseSession.legacy_msg_id_to_xmpp_msg_id", legacy_to_xmpp_msg_id) 

644 ) 

645 

646 @staticmethod 

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

648 """ 

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

650 Needed for read marks and message corrections. 

651 

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

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

654 or to add some additional, 

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

656 

657 The default implementation is an identity function. 

658 

659 :param i: The XMPP stanza ID 

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

661 """ 

662 return cast(LegacyMessageType, i) 

663 

664 xmpp_msg_id_to_legacy_msg_id = staticmethod( 

665 deprecated("BaseSession.xmpp_msg_id_to_legacy_msg_id", xmpp_to_legacy_msg_id) 

666 ) 

667 

668 def raise_if_not_logged(self) -> None: 

669 if not self.logged: 

670 raise XMPPError( 

671 "internal-server-error", 

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

673 ) 

674 

675 @classmethod 

676 def _from_user_or_none(cls, user: GatewayUser | None) -> Self: 

677 if user is None: 

678 log.debug("user not found") 

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

680 

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

682 if session is None: 

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

684 assert isinstance(session, cls) 

685 return session 

686 

687 @classmethod 

688 def from_user(cls, user: GatewayUser) -> Self: 

689 return cls._from_user_or_none(user) 

690 

691 @classmethod 

692 def from_stanza(cls, s: Message | Iq | Presence) -> Self: 

693 # """ 

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

695 # 

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

697 # 

698 # :param s: 

699 # :return: 

700 # """ 

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

702 

703 @classmethod 

704 def from_jid(cls, jid: JID) -> Self: 

705 # """ 

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

707 # 

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

709 # 

710 # :param jid: 

711 # :return: 

712 # """ 

713 session = _sessions.get(jid.bare) 

714 if session is not None: 

715 assert isinstance(session, cls) 

716 return session 

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

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

719 return cls._from_user_or_none(user) 

720 

721 @classmethod 

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

723 # """ 

724 # Terminate a user session. 

725 # 

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

727 # 

728 # :param jid: 

729 # :return: 

730 # """ 

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

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

733 if user_jid == jid.bare: 

734 break 

735 else: 

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

737 return 

738 for c in session.contacts: 

739 c.unsubscribe() 

740 for m in session.bookmarks: 

741 m.shutdown() 

742 

743 try: 

744 session = _sessions.pop(jid.bare) 

745 except KeyError: 

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

747 return 

748 

749 session.cancel_all_tasks() 

750 

751 await cls.xmpp.unregister(session) 

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

753 orm.delete(session.user) 

754 orm.commit() 

755 

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

757 if not self.xmpp.PROPER_RECEIPTS: 

758 self.xmpp.delivery_receipt.ack(msg) 

759 

760 def send_gateway_status( 

761 self, 

762 status: str | None = None, 

763 show: PresenceShows | None = None, 

764 **kwargs: Any, # noqa 

765 ) -> None: 

766 """ 

767 Send a presence from the gateway to the user. 

768 

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

770 

771 :param status: A status message 

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

773 that the gateway is not fully functional 

774 """ 

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

776 self.xmpp.send_presence( 

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

778 ) 

779 

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

781 if not self.__cached_presence: 

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

783 return 

784 self.xmpp.send_presence( 

785 pto=to, 

786 pstatus=self.__cached_presence.status, 

787 pshow=self.__cached_presence.show, 

788 **self.__cached_presence.kwargs, 

789 ) 

790 

791 def send_gateway_message( 

792 self, 

793 text: str, 

794 **msg_kwargs: Any, # noqa 

795 ) -> None: 

796 """ 

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

798 

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

800 

801 :param text: A text 

802 """ 

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

804 

805 def send_gateway_invite( 

806 self, 

807 muc: AnyMUC, 

808 reason: str | None = None, 

809 password: str | None = None, 

810 ) -> None: 

811 """ 

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

813 

814 :param muc: 

815 :param reason: 

816 :param password: 

817 """ 

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

819 

820 async def input(self, text: str, **msg_kwargs: Any) -> str: # noqa 

821 """ 

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

823 

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

825 

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

827 :param msg_kwargs: Extra attributes 

828 :return: 

829 """ 

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

831 

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

833 """ 

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

835 ``self.user`` 

836 

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

838 """ 

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

840 

841 async def get_contact_or_group_or_participant( 

842 self, jid: JID, create: bool = True 

843 ) -> ( 

844 LegacyContact[Any] | LegacyMUC[Any, Any, Any, Any] | "LegacyParticipant" | None 

845 ): 

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

847 return contact # type:ignore[no-any-return] 

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

849 return await self.__get_muc_or_participant(muc, jid) 

850 else: 

851 muc = None 

852 

853 if not create: 

854 return None 

855 

856 try: 

857 return await self.contacts.by_jid(jid) # type:ignore[no-any-return] 

858 except XMPPError: 

859 if muc is None: 

860 try: 

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

862 except XMPPError: 

863 return None 

864 return await self.__get_muc_or_participant(muc, jid) 

865 

866 @staticmethod 

867 async def __get_muc_or_participant( 

868 muc: AnyMUC, jid: JID 

869 ) -> "AnyMUC | LegacyParticipant | None": 

870 if nick := jid.resource: 

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

872 return muc 

873 

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

875 # """ 

876 # Wait until session, contacts and bookmarks are ready 

877 # 

878 # (slidge internal use) 

879 # 

880 # :param timeout: 

881 # :return: 

882 # """ 

883 try: 

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

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

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

887 except TimeoutError: 

888 raise XMPPError( 

889 "recipient-unavailable", 

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

891 ) 

892 

893 def legacy_module_data_update(self, data: JSONSerializable) -> None: 

894 user = self.user 

895 user.legacy_module_data.update(data) 

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

897 

898 def legacy_module_data_set(self, data: JSONSerializable) -> None: 

899 user = self.user 

900 user.legacy_module_data = data 

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

902 

903 def legacy_module_data_clear(self) -> None: 

904 user = self.user 

905 user.legacy_module_data.clear() 

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

907 

908 

909# keys = user.jid.bare 

910_sessions: dict[str, AnySession] = {} 

911log = logging.getLogger(__name__)