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

262 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 05:07 +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 Union, 

13 cast, 

14) 

15 

16import aiohttp 

17import sqlalchemy as sa 

18from slixmpp import JID, Iq, Message, Presence 

19from slixmpp.exceptions import XMPPError 

20from slixmpp.types import PresenceShows, ResourceDict 

21 

22from slidge.db.meta import JSONSerializable 

23 

24from ..command import SearchResult 

25from ..contact import LegacyContact 

26from ..db.models import Contact, GatewayUser 

27from ..group.room import LegacyMUC 

28from ..util import SubclassableOnce 

29from ..util.lock import NamedLockMixin 

30from ..util.types import ( 

31 AnyBookmarks, 

32 AnyContact, 

33 AnyMUC, 

34 AnyRoster, 

35 AnySession, 

36 LegacyGroupIdType, 

37 LegacyMessageType, 

38 LegacyThreadType, 

39 LinkPreview, 

40 Mention, 

41 PseudoPresenceShow, 

42 RecipientType, 

43 Sticker, 

44) 

45from ..util.util import deprecated, noop_coro 

46 

47if TYPE_CHECKING: 

48 from ..group.participant import LegacyParticipant 

49 from ..util.types import Sender 

50 from .gateway import BaseGateway 

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: "BaseGateway" 

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_preferences( 

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

554 ) -> None: 

555 """ 

556 This is called when the user updates their preferences. 

557 

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

559 something when a preference has changed. 

560 """ 

561 raise NotImplementedError 

562 

563 def __reset_ready(self) -> None: 

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

565 

566 @property 

567 def logged(self) -> bool: 

568 return self._logged 

569 

570 @logged.setter 

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

572 self.is_logging_in = False 

573 self._logged = v 

574 if self.ready.done(): 

575 if v: 

576 return 

577 self.__reset_ready() 

578 self.shutdown(logout=False) 

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

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

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

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

583 orm.commit() 

584 else: 

585 if v: 

586 self.ready.set_result(True) 

587 

588 def __repr__(self) -> str: 

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

590 

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

592 for m in self.bookmarks: 

593 m.shutdown() 

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

595 for jid in orm.execute( 

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

597 ).scalars(): 

598 pres = self.xmpp.make_presence( 

599 pfrom=jid, 

600 pto=self.user_jid, 

601 ptype="unavailable", 

602 pstatus="Gateway has shut down.", 

603 ) 

604 pres.send() 

605 if logout: 

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

607 else: 

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

609 

610 async def __logout(self) -> None: 

611 try: 

612 await self.logout() 

613 except NotImplementedError: 

614 pass 

615 except KeyboardInterrupt: 

616 pass 

617 

618 @staticmethod 

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

620 """ 

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

622 Needed for read marks, retractions and message corrections. 

623 

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

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

626 or to add some additional, 

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

628 

629 :param legacy_msg_id: 

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

631 """ 

632 return str(legacy_msg_id) 

633 

634 legacy_msg_id_to_xmpp_msg_id = staticmethod( 

635 deprecated("BaseSession.legacy_msg_id_to_xmpp_msg_id", legacy_to_xmpp_msg_id) 

636 ) 

637 

638 @staticmethod 

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

640 """ 

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

642 Needed for read marks and message corrections. 

643 

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

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

646 or to add some additional, 

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

648 

649 The default implementation is an identity function. 

650 

651 :param i: The XMPP stanza ID 

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

653 """ 

654 return cast(LegacyMessageType, i) 

655 

656 xmpp_msg_id_to_legacy_msg_id = staticmethod( 

657 deprecated("BaseSession.xmpp_msg_id_to_legacy_msg_id", xmpp_to_legacy_msg_id) 

658 ) 

659 

660 def raise_if_not_logged(self) -> None: 

661 if not self.logged: 

662 raise XMPPError( 

663 "internal-server-error", 

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

665 ) 

666 

667 @classmethod 

668 def _from_user_or_none(cls, user: GatewayUser | None) -> AnySession: 

669 if user is None: 

670 log.debug("user not found") 

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

672 

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

674 if session is None: 

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

676 return session 

677 

678 @classmethod 

679 def from_user(cls, user: GatewayUser) -> "AnySession": 

680 return cls._from_user_or_none(user) 

681 

682 @classmethod 

683 def from_stanza(cls, s: Message | Iq | Presence) -> "AnySession": 

684 # """ 

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

686 # 

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

688 # 

689 # :param s: 

690 # :return: 

691 # """ 

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

693 

694 @classmethod 

695 def from_jid(cls, jid: JID) -> AnySession: 

696 # """ 

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

698 # 

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

700 # 

701 # :param jid: 

702 # :return: 

703 # """ 

704 session = _sessions.get(jid.bare) 

705 if session is not None: 

706 return session 

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

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

709 return cls._from_user_or_none(user) 

710 

711 @classmethod 

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

713 # """ 

714 # Terminate a user session. 

715 # 

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

717 # 

718 # :param jid: 

719 # :return: 

720 # """ 

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

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

723 if user_jid == jid.bare: 

724 break 

725 else: 

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

727 return 

728 for c in session.contacts: 

729 c.unsubscribe() 

730 for m in session.bookmarks: 

731 m.shutdown() 

732 

733 try: 

734 session = _sessions.pop(jid.bare) 

735 except KeyError: 

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

737 return 

738 

739 session.cancel_all_tasks() 

740 

741 await cls.xmpp.unregister(session) 

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

743 orm.delete(session.user) 

744 orm.commit() 

745 

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

747 if not self.xmpp.PROPER_RECEIPTS: 

748 self.xmpp.delivery_receipt.ack(msg) 

749 

750 def send_gateway_status( 

751 self, 

752 status: str | None = None, 

753 show: PresenceShows | None = None, 

754 **kwargs: Any, # noqa 

755 ) -> None: 

756 """ 

757 Send a presence from the gateway to the user. 

758 

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

760 

761 :param status: A status message 

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

763 that the gateway is not fully functional 

764 """ 

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

766 self.xmpp.send_presence( 

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

768 ) 

769 

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

771 if not self.__cached_presence: 

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

773 return 

774 self.xmpp.send_presence( 

775 pto=to, 

776 pstatus=self.__cached_presence.status, 

777 pshow=self.__cached_presence.show, 

778 **self.__cached_presence.kwargs, 

779 ) 

780 

781 def send_gateway_message( 

782 self, 

783 text: str, 

784 **msg_kwargs: Any, # noqa 

785 ) -> None: 

786 """ 

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

788 

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

790 

791 :param text: A text 

792 """ 

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

794 

795 def send_gateway_invite( 

796 self, 

797 muc: AnyMUC, 

798 reason: str | None = None, 

799 password: str | None = None, 

800 ) -> None: 

801 """ 

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

803 

804 :param muc: 

805 :param reason: 

806 :param password: 

807 """ 

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

809 

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

811 """ 

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

813 

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

815 

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

817 :param msg_kwargs: Extra attributes 

818 :return: 

819 """ 

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

821 

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

823 """ 

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

825 ``self.user`` 

826 

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

828 """ 

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

830 

831 async def get_contact_or_group_or_participant( 

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

833 ) -> ( 

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

835 ): 

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

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

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

839 return await self.__get_muc_or_participant(muc, jid) 

840 else: 

841 muc = None 

842 

843 if not create: 

844 return None 

845 

846 try: 

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

848 except XMPPError: 

849 if muc is None: 

850 try: 

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

852 except XMPPError: 

853 return None 

854 return await self.__get_muc_or_participant(muc, jid) 

855 

856 @staticmethod 

857 async def __get_muc_or_participant( 

858 muc: AnyMUC, jid: JID 

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

860 if nick := jid.resource: 

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

862 return muc 

863 

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

865 # """ 

866 # Wait until session, contacts and bookmarks are ready 

867 # 

868 # (slidge internal use) 

869 # 

870 # :param timeout: 

871 # :return: 

872 # """ 

873 try: 

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

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

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

877 except TimeoutError: 

878 raise XMPPError( 

879 "recipient-unavailable", 

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

881 ) 

882 

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

884 user = self.user 

885 user.legacy_module_data.update(data) 

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

887 

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

889 user = self.user 

890 user.legacy_module_data = data 

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

892 

893 def legacy_module_data_clear(self) -> None: 

894 user = self.user 

895 user.legacy_module_data.clear() 

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

897 

898 

899# keys = user.jid.bare 

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

901log = logging.getLogger(__name__)