Coverage for slidge/core/gateway.py: 63%

462 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-18 04:30 +0000

1""" 

2This module extends slixmpp.ComponentXMPP to make writing new LegacyClients easier 

3""" 

4 

5from __future__ import annotations 

6 

7import abc 

8import asyncio 

9import logging 

10import re 

11import sys 

12import tempfile 

13from collections.abc import Callable, Sequence 

14from copy import copy 

15from datetime import UTC, datetime 

16from pathlib import Path 

17from typing import Any, ClassVar, Concatenate, Generic, ParamSpec, TypeVar 

18 

19import aiohttp 

20from slixmpp import JID, ComponentXMPP, Iq 

21from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

22from slixmpp.plugins.xep_0004.stanza.form import Form 

23from slixmpp.plugins.xep_0060.stanza import OwnerAffiliation 

24from slixmpp.plugins.xep_0356.privilege import PrivilegedIqError 

25from slixmpp.types import MessageTypes 

26from slixmpp.xmlstream.xmlstream import NotConnectedError 

27from sqlalchemy.orm import Session as OrmSession 

28 

29import slidge.command.categories 

30from slidge.command import BUILTIN_COMMANDS 

31from slidge.command.adhoc import AdhocProvider 

32from slidge.command.admin import Exec 

33from slidge.command.base import ( 

34 Command, 

35 CommandBase, 

36 ContactCommand, 

37 FormField, 

38 MUCCommand, 

39) 

40from slidge.command.chat_command import ChatCommandProvider 

41from slidge.command.register import RegistrationType 

42from slidge.contact import LegacyContact 

43from slidge.core import config 

44from slidge.core.attachment_upload import AttachmentUploader 

45from slidge.core.dispatcher.session_dispatcher import SessionDispatcher 

46from slidge.core.mixins.avatar import convert_avatar 

47from slidge.core.mixins.message import ContentMessageMixin, InviteMixin 

48from slidge.core.pubsub import PubSubComponent 

49from slidge.db import GatewayUser, SlidgeStore 

50from slidge.db.avatar import CachedAvatar, avatar_cache 

51from slidge.db.meta import JSONSerializable 

52from slidge.group import LegacyMUC 

53from slidge.slixfix.delivery_receipt import DeliveryReceipt 

54from slidge.slixfix.roster import RosterBackend 

55from slidge.util.types import ( 

56 AnySession, 

57 Avatar, 

58 MessageOrPresenceTypeVar, 

59 SessionType_co, 

60) 

61from slidge.util.util import derive_wired_class 

62 

63T = TypeVar("T") 

64P = ParamSpec("P") 

65 

66 

67class BaseGateway( 

68 ComponentXMPP, 

69 InviteMixin, 

70 ContentMessageMixin, 

71 abc.ABC, 

72 Generic[SessionType_co], # noqa: UP046 (mypy cannot infer variance with PEP 695 syntax) 

73): 

74 """ 

75 The gateway component, handling registrations and un-registrations. 

76 

77 On slidge launch, a singleton is instantiated, and it will be made available 

78 to public classes such :class:`.LegacyContact` or :class:`.BaseSession` as the 

79 ``.xmpp`` attribute. 

80 

81 Must be subclassed by a legacy module to set up various aspects of the XMPP 

82 component behaviour, such as its display name or welcome message, via 

83 class attributes :attr:`.COMPONENT_NAME` :attr:`.WELCOME_MESSAGE`. 

84 

85 Abstract methods related to the registration process must be overriden 

86 for a functional :term:`Legacy Module`: 

87 

88 - :meth:`.validate` 

89 - :meth:`.validate_two_factor_code` 

90 - :meth:`.get_qr_text` 

91 - :meth:`.confirm_qr` 

92 

93 NB: Not all of these must be overridden, it depends on the 

94 :attr:`REGISTRATION_TYPE`. 

95 

96 The other methods, such as :meth:`.send_text` or :meth:`.react` are the same 

97 as those of :class:`.LegacyContact` and :class:`.LegacyParticipant`, because 

98 the component itself is also a "messaging actor", ie, an :term:`XMPP Entity`. 

99 For these methods, you need to specify the JID of the recipient with the 

100 `mto` parameter. 

101 

102 Since it inherits from :class:`slixmpp.componentxmpp.ComponentXMPP`,you also 

103 have a hand on low-level XMPP interactions via slixmpp methods, e.g.: 

104 

105 .. code-block:: python 

106 

107 self.send_presence( 

108 pfrom="somebody@component.example.com", 

109 pto="someonwelse@anotherexample.com", 

110 ) 

111 

112 However, you should not need to do so often since the classes of the plugin 

113 API provides higher level abstractions around most commonly needed use-cases, such 

114 as sending messages, or displaying a custom status. 

115 

116 """ 

117 

118 COMPONENT_NAME: str = NotImplemented 

119 """Name of the component, as seen in service discovery by XMPP clients""" 

120 COMPONENT_TYPE: str = "" 

121 """Type of the gateway, should follow https://xmpp.org/registrar/disco-categories.html""" 

122 COMPONENT_AVATAR: Avatar | Path | str | None = None 

123 """ 

124 Path, bytes or URL used by the component as an avatar. 

125 """ 

126 

127 REGISTRATION_FIELDS: ClassVar[Sequence[FormField]] = [ 

128 FormField(var="username", label="User name", required=True), 

129 FormField(var="password", label="Password", required=True, private=True), 

130 ] 

131 """ 

132 Iterable of fields presented to the gateway user when registering using :xep:`0077` 

133 `extended <https://xmpp.org/extensions/xep-0077.html#extensibility>`_ by :xep:`0004`. 

134 """ 

135 REGISTRATION_INSTRUCTIONS: str = "Enter your credentials" 

136 """ 

137 The text presented to a user who wants to register (or modify) their 

138 :term:`legacy <Legacy>` account configuration. 

139 """ 

140 REGISTRATION_TYPE: RegistrationType = RegistrationType.SINGLE_STEP_FORM 

141 """ 

142 This attribute determines how users register to the gateway, ie, how they 

143 login to the :term:`legacy network <Legacy Network>`. 

144 The credentials are then stored persistently, so this process should happen 

145 once per user (unless they unregister). 

146 

147 The registration process always start with a basic data form (:xep:`0004`) 

148 presented to the user. 

149 But the legacy login flow might require something more sophisticated, see 

150 :class:`.RegistrationType` for more details. 

151 """ 

152 

153 REGISTRATION_2FA_TITLE = "Enter your 2FA code" 

154 REGISTRATION_2FA_INSTRUCTIONS = ( 

155 "You should have received something via email or SMS, or something" 

156 ) 

157 REGISTRATION_QR_INSTRUCTIONS = "Flash this code or follow this link" 

158 

159 PREFERENCES: ClassVar[list[FormField]] = [ 

160 FormField( 

161 var="sync_presence", 

162 label="Propagate your XMPP presence to the legacy network.", 

163 value="true", 

164 required=True, 

165 type="boolean", 

166 ), 

167 FormField( 

168 var="sync_avatar", 

169 label="Propagate your XMPP avatar to the legacy network.", 

170 value="true", 

171 required=True, 

172 type="boolean", 

173 ), 

174 FormField( 

175 var="always_invite_when_adding_bookmarks", 

176 label="Send an invitation to join MUCs after adding them to the bookmarks.", 

177 value="true", 

178 required=True, 

179 type="boolean", 

180 ), 

181 FormField( 

182 var="last_seen_fallback", 

183 label="Use contact presence status message to show when they were last seen.", 

184 value="true", 

185 required=True, 

186 type="boolean", 

187 ), 

188 FormField( 

189 var="roster_push", 

190 label="Add contacts to your roster.", 

191 value="true", 

192 required=True, 

193 type="boolean", 

194 ), 

195 FormField( 

196 var="reaction_fallback", 

197 label="Receive fallback messages for reactions (for legacy XMPP clients)", 

198 value="false", 

199 required=True, 

200 type="boolean", 

201 ), 

202 ] 

203 

204 ROSTER_GROUP: str = "slidge" 

205 """ 

206 Name of the group assigned to a :class:`.LegacyContact` automagically 

207 added to the :term:`User`'s roster with :meth:`.LegacyContact.add_to_roster`. 

208 """ 

209 WELCOME_MESSAGE = ( 

210 "Thank you for registering. Type 'help' to list the available commands, " 

211 "or just start messaging away!" 

212 ) 

213 """ 

214 A welcome message displayed to users on registration. 

215 This is useful notably for clients that don't consider component JIDs as a 

216 valid recipient in their UI, yet still open a functional chat window on 

217 incoming messages from components. 

218 """ 

219 

220 SEARCH_FIELDS: ClassVar[Sequence[FormField]] = [ 

221 FormField(var="first", label="First name", required=True), 

222 FormField(var="last", label="Last name", required=True), 

223 FormField(var="phone", label="Phone number", required=False), 

224 ] 

225 """ 

226 Fields used for searching items via the component, through :xep:`0055` (jabber search). 

227 A common use case is to allow users to search for legacy contacts by something else than 

228 their usernames, eg their phone number. 

229 

230 Plugins should implement search by overriding :meth:`.BaseSession.search` 

231 (restricted to registered users). 

232 

233 If there is only one field, it can also be used via the ``jabber:iq:gateway`` protocol 

234 described in :xep:`0100`. Limitation: this only works if the search request returns 

235 one result item, and if this item has a 'jid' var. 

236 """ 

237 SEARCH_TITLE: str = "Search for legacy contacts" 

238 """ 

239 Title of the search form. 

240 """ 

241 SEARCH_INSTRUCTIONS: str = "" 

242 """ 

243 Instructions of the search form. 

244 """ 

245 

246 MARK_ALL_MESSAGES = False 

247 """ 

248 Set this to True for :term:`legacy networks <Legacy Network>` that expects 

249 read marks for *all* messages and not just the latest one that was read 

250 (as most XMPP clients will only send a read mark for the latest msg). 

251 """ 

252 

253 PROPER_RECEIPTS = False 

254 """ 

255 Set this to True if the legacy service provides a real equivalent of message delivery receipts 

256 (:xep:`0184`), meaning that there is an event thrown when the actual device of a contact receives 

257 a message. Make sure to call Contact.received() adequately if this is set to True. 

258 """ 

259 

260 GROUPS = False 

261 """ 

262 This must be set to True if this gateway supports groups. 

263 """ 

264 SPACES = False 

265 """ 

266 This must be set to True if this gateway supports spaces, cf :xep:`0503`. 

267 """ 

268 

269 mtype: MessageTypes = "chat" 

270 is_group = False 

271 _can_send_carbon = False 

272 store: SlidgeStore 

273 

274 session_cls: type[SessionType_co] 

275 """Concrete :class:`.BaseSession` subclass of this legacy module. 

276 

277 Derived automatically from the generic parameter, e.g., 

278 ``class Gateway(BaseGateway[Session])``. 

279 """ 

280 

281 def __init_subclass__(cls, **kwargs: object) -> None: 

282 super().__init_subclass__(**kwargs) 

283 derive_wired_class(cls, BaseGateway, "session_cls") 

284 

285 COMMANDS: ClassVar[tuple[type[CommandBase], ...]] = BUILTIN_COMMANDS 

286 """Commands (adhoc and chatbot) this gateway provides. 

287 

288 Subclasses may override this with additional custom commands. 

289 E.g.: ``COMMANDS = BaseGateway.COMMANDS + commands_from_module(command)``. 

290 """ 

291 

292 http: aiohttp.ClientSession 

293 avatar: CachedAvatar | None = None 

294 

295 def __init__(self) -> None: 

296 if getattr(self, "session_cls", None) is None: 

297 raise RuntimeError( 

298 f"{type(self).__name__}.session_cls is not set. Your gateway" 

299 " class must be parameterized with its BaseSession subclass," 

300 " e.g. 'class Gateway(BaseGateway[Session])'." 

301 ) 

302 if config.COMPONENT_NAME: 

303 self.COMPONENT_NAME = config.COMPONENT_NAME 

304 if config.WELCOME_MESSAGE: 

305 self.WELCOME_MESSAGE = config.WELCOME_MESSAGE 

306 self.log = log 

307 self.datetime_started = datetime.now(tz=UTC) 

308 # FIXME: ugly hack to work with the BaseSender mixin :/ 

309 self.xmpp = self 

310 self.default_ns = "jabber:component:accept" 

311 super().__init__( 

312 config.JID, 

313 config.SECRET, 

314 config.SERVER, 

315 config.PORT, 

316 plugin_whitelist=SLIXMPP_PLUGINS, 

317 plugin_config={ 

318 "xep_0077": { 

319 "form_fields": None, 

320 "form_instructions": self.REGISTRATION_INSTRUCTIONS, 

321 "enable_subscription": self.REGISTRATION_TYPE 

322 == RegistrationType.SINGLE_STEP_FORM, 

323 }, 

324 "xep_0100": { 

325 "component_name": self.COMPONENT_NAME, 

326 "type": self.COMPONENT_TYPE, 

327 }, 

328 "xep_0184": { 

329 "auto_ack": False, 

330 "auto_request": False, 

331 }, 

332 "xep_0363": { 

333 "upload_service": config.UPLOAD_SERVICE, 

334 }, 

335 }, 

336 fix_error_ns=True, 

337 ) 

338 self.loop.set_exception_handler(self.__exception_handler) 

339 self.loop.create_task(self.__set_http()) 

340 self.has_crashed: bool = False 

341 self.use_origin_id = False 

342 

343 if config.USER_JID_VALIDATOR is None: 

344 config.USER_JID_VALIDATOR = f".*@{self.infer_real_domain()}" 

345 log.info( 

346 "No USER_JID_VALIDATOR was set, using '%s'.", 

347 config.USER_JID_VALIDATOR, 

348 ) 

349 self.jid_validator: re.Pattern[str] = re.compile(config.USER_JID_VALIDATOR) 

350 self.qr_pending_registrations = dict[ 

351 str, asyncio.Future[JSONSerializable | None] 

352 ]() 

353 

354 self.register_plugins() 

355 self.__setup_session_cls() 

356 

357 self.get_session_from_stanza = self.session_cls.from_stanza 

358 self.get_session_from_user = self.session_cls.from_user 

359 

360 self.__register_slixmpp_events() 

361 self.__register_slixmpp_api() 

362 self.roster.set_backend(RosterBackend(self)) 

363 

364 self.register_plugin("pubsub", {"component_name": self.COMPONENT_NAME}) 

365 self.pubsub: PubSubComponent = self.plugin["pubsub"] # type:ignore[typeddict-item] 

366 self.delivery_receipt = DeliveryReceipt(self) 

367 

368 # with this we receive user avatar updates 

369 self.plugin["xep_0030"].add_feature("urn:xmpp:avatar:metadata+notify") 

370 

371 self.plugin["xep_0030"].add_feature("urn:xmpp:chat-markers:0") 

372 

373 if self.GROUPS: 

374 self.plugin["xep_0030"].add_feature("http://jabber.org/protocol/muc") 

375 self.plugin["xep_0030"].add_feature(self.plugin["xep_0463"].stanza.NS) 

376 self.plugin["xep_0030"].add_feature("urn:xmpp:mam:2") 

377 self.plugin["xep_0030"].add_feature("urn:xmpp:mam:2#extended") 

378 self.plugin["xep_0030"].add_feature(self.plugin["xep_0421"].namespace) 

379 self.plugin["xep_0030"].add_feature(self.plugin["xep_0317"].stanza.NS) 

380 self.plugin["xep_0030"].add_identity( 

381 category="conference", 

382 name=self.COMPONENT_NAME, 

383 itype="text", 

384 jid=self.boundjid, 

385 ) 

386 if self.SPACES: 

387 self.plugin["xep_0030"].add_feature("urn:xmpp:spaces:0") 

388 

389 self.__adhoc_handler = AdhocProvider(self) 

390 self.__chat_commands_handler = ChatCommandProvider(self) 

391 

392 self.__dispatcher = SessionDispatcher(self) 

393 

394 self.__register_commands() 

395 

396 def __setup_session_cls(self) -> None: 

397 contact_cls = self.session_cls.roster_cls.contact_cls 

398 

399 if contact_cls.REACTIONS_SINGLE_EMOJI: 

400 form = Form() 

401 form["type"] = "result" 

402 form.add_field( 

403 "FORM_TYPE", "hidden", value="urn:xmpp:reactions:0:restrictions" 

404 ) 

405 form.add_field("max_reactions_per_user", value="1", type="text-single") 

406 form.add_field("scope", value="domain") 

407 self.plugin["xep_0128"].add_extended_info(data=form) 

408 

409 self.session_cls.xmpp = self 

410 

411 @property 

412 def _uploader(self) -> AttachmentUploader: 

413 # the component itself is not bound to any user session 

414 return AttachmentUploader(self, None) 

415 

416 async def kill_session(self, jid: JID) -> None: 

417 await self.session_cls.kill_by_jid(jid) 

418 

419 async def __set_http(self) -> None: 

420 self.http = aiohttp.ClientSession() 

421 if getattr(self, "_test_mode", False): 

422 return 

423 avatar_cache.http = self.http 

424 

425 def __register_commands(self) -> None: 

426 for cls in self.COMMANDS: 

427 if any(x is NotImplemented for x in [cls.CHAT_COMMAND, cls.NODE, cls.NAME]): 

428 raise TypeError( 

429 f"{cls} is listed in {type(self).__name__}.COMMANDS but" 

430 " does not set NAME, NODE and CHAT_COMMAND." 

431 ) 

432 if issubclass(cls, ContactCommand): 

433 LegacyContact.commands[cls.NODE] = cls 

434 LegacyContact.commands_chat[cls.CHAT_COMMAND] = cls 

435 continue 

436 if issubclass(cls, MUCCommand): 

437 LegacyMUC.commands[cls.NODE] = cls 

438 LegacyMUC.commands_chat[cls.CHAT_COMMAND] = cls 

439 continue 

440 if not issubclass(cls, Command): 

441 raise TypeError( 

442 f"{cls} is listed in {type(self).__name__}.COMMANDS but is" 

443 " not a Command, ContactCommand or MUCCommand subclass." 

444 ) 

445 if cls is Exec: 

446 if config.DEV_MODE: 

447 log.warning(r"/!\ DEV MODE ENABLED /!\\") 

448 else: 

449 continue 

450 if cls.CATEGORY == slidge.command.categories.GROUPS and not self.GROUPS: 

451 continue 

452 if cls.CATEGORY == slidge.command.categories.SPACES and not self.SPACES: 

453 continue 

454 c = cls(self) 

455 log.debug("Registering %s", cls) 

456 self.__adhoc_handler.register(c) 

457 self.__chat_commands_handler.register(c) 

458 

459 def __exception_handler( 

460 self, loop: asyncio.AbstractEventLoop, context: dict[Any, Any] 

461 ) -> None: 

462 """ 

463 Called when a task created by loop.create_task() raises an Exception 

464 

465 :param loop: 

466 :param context: 

467 :return: 

468 """ 

469 log.debug("Context in the exception handler: %s", context) 

470 exc = context.get("exception") 

471 if exc is None: 

472 log.debug("No exception in this context: %s", context) 

473 elif isinstance(exc, SystemExit): 

474 log.debug("SystemExit called in an asyncio task") 

475 else: 

476 log.error("Crash in an asyncio task: %s", context) 

477 log.exception("Crash in task", exc_info=exc) 

478 self.has_crashed = True 

479 loop.stop() 

480 

481 def __register_slixmpp_events(self) -> None: 

482 self.del_event_handler("presence_subscribe", self._handle_subscribe) 

483 self.del_event_handler("presence_unsubscribe", self._handle_unsubscribe) 

484 self.del_event_handler("presence_subscribed", self._handle_subscribed) 

485 self.del_event_handler("presence_unsubscribed", self._handle_unsubscribed) 

486 self.del_event_handler( 

487 "roster_subscription_request", self._handle_new_subscription 

488 ) 

489 self.del_event_handler("presence_probe", self._handle_probe) 

490 self.add_event_handler("session_start", self.__on_session_start) 

491 

492 def __register_slixmpp_api(self) -> None: 

493 def with_session( 

494 func: Callable[Concatenate[OrmSession, P], T], commit: bool = True 

495 ) -> Callable[P, T]: 

496 def wrapped(*a: P.args, **kw: P.kwargs) -> T: 

497 with self.store.session() as orm: 

498 res = func(orm, *a, **kw) 

499 if commit: 

500 orm.commit() 

501 return res 

502 

503 return wrapped 

504 

505 self.plugin["xep_0231"].api.register( 

506 with_session(self.store.bob.get_bob, False), "get_bob" 

507 ) 

508 self.plugin["xep_0231"].api.register( 

509 with_session(self.store.bob.set_bob), "set_bob" 

510 ) 

511 self.plugin["xep_0231"].api.register( 

512 with_session(self.store.bob.del_bob), "del_bob" 

513 ) 

514 

515 @property # type:ignore[override] 

516 def jid(self) -> JID: 

517 # Override to avoid slixmpp deprecation warnings. 

518 return self.boundjid 

519 

520 @jid.setter 

521 def jid(self, jid: JID) -> None: 

522 raise RuntimeError 

523 

524 async def __on_session_start(self, event: object) -> None: 

525 log.debug("Gateway session start: %s", event) 

526 

527 await self.__setup_attachments() 

528 

529 # prevents XMPP clients from considering the gateway as an HTTP upload 

530 disco = self.plugin["xep_0030"] 

531 await disco.del_feature(feature="urn:xmpp:http:upload:0", jid=self.boundjid) 

532 await self.plugin["xep_0115"].update_caps(jid=self.boundjid) 

533 

534 if self.COMPONENT_AVATAR is not None: 

535 log.debug("Setting gateway avatar…") 

536 avatar = convert_avatar(self.COMPONENT_AVATAR, "!!---slidge---special---") 

537 assert avatar is not None 

538 try: 

539 cached_avatar = await avatar_cache.convert_or_get(avatar) 

540 except Exception as e: 

541 log.exception("Could not set the component avatar.", exc_info=e) 

542 cached_avatar = None 

543 else: 

544 assert cached_avatar is not None 

545 self.avatar = cached_avatar 

546 else: 

547 cached_avatar = None 

548 

549 with self.store.session() as orm: 

550 users = orm.query(GatewayUser).all() 

551 for user in users: 

552 # TODO: before this, we should check if the user has removed us from their roster 

553 # while we were offline and trigger unregister from there. Presence probe does not seem 

554 # to work in this case, there must be another way. privileged entity could be used 

555 # as last resort. 

556 try: 

557 await self["xep_0100"].add_component_to_roster(user.jid) 

558 await self.__add_component_to_mds_whitelist(user.jid) 

559 except (IqError, IqTimeout) as e: 

560 # TODO: remove the user when this happens? or at least 

561 # this can happen when the user has unsubscribed from the XMPP server 

562 log.warning( 

563 "Error with user %s, not logging them automatically", 

564 user, 

565 exc_info=e, 

566 ) 

567 continue 

568 session = self.session_cls.from_user(user) 

569 session.create_task(self.login_wrap(session)) 

570 if cached_avatar is not None: 

571 await self.pubsub.broadcast_avatar( 

572 self.boundjid.bare, session.user_jid, cached_avatar 

573 ) 

574 

575 log.info("Slidge has successfully started") 

576 

577 async def __setup_attachments(self) -> None: 

578 if config.NO_UPLOAD_PATH: 

579 if config.NO_UPLOAD_URL_PREFIX is None: 

580 raise RuntimeError( 

581 "If you set NO_UPLOAD_PATH you must set NO_UPLOAD_URL_PREFIX too." 

582 ) 

583 elif not config.UPLOAD_SERVICE: 

584 try: 

585 info_iq = await self.xmpp.plugin["xep_0363"].find_upload_service( 

586 self.infer_real_domain() 

587 ) 

588 except XMPPError: 

589 info_iq = None 

590 log.exception( 

591 "The upload service could not be automatically determine. " 

592 "Attachments to XMPP will not work. " 

593 "Either specify 'upload-service' or 'no-upload-path' to fix that." 

594 ) 

595 if info_iq is None: 

596 if self.REGISTRATION_TYPE == RegistrationType.QRCODE: 

597 log.warning( 

598 "No method was configured for attachment and slidge " 

599 "could not automatically determine the JID of a usable upload service. " 

600 "Users likely won't be able to register since this network uses a " 

601 "QR-code based registration flow." 

602 ) 

603 if not config.USE_ATTACHMENT_ORIGINAL_URLS: 

604 log.warning( 

605 "Setting USE_ATTACHMENT_ORIGINAL_URLS to True since no method was configured " 

606 "for attachments and no upload service was found. NB: this does not work for all " 

607 "networks, especially for the E2EE attachments." 

608 ) 

609 config.USE_ATTACHMENT_ORIGINAL_URLS = True 

610 else: 

611 log.info("Auto-discovered upload service: %s", info_iq["from"]) 

612 config.UPLOAD_SERVICE = info_iq["from"] 

613 

614 def infer_real_domain(self) -> JID: 

615 return JID(re.sub(r"^.*?\.", "", self.xmpp.boundjid.bare)) 

616 

617 async def __add_component_to_mds_whitelist(self, user_jid: JID) -> None: 

618 # Uses privileged entity to add ourselves to the whitelist of the PEP 

619 # MDS node so we receive MDS events 

620 iq_creation = Iq(sto=user_jid.bare, sfrom=user_jid, stype="set") 

621 iq_creation["pubsub"]["create"]["node"] = self.plugin["xep_0490"].stanza.NS 

622 

623 try: 

624 await self.plugin["xep_0356"].send_privileged_iq(iq_creation) 

625 except PermissionError: 

626 log.warning( 

627 "IQ privileges not granted for pubsub namespace, we cannot " 

628 "create the MDS node of %s", 

629 user_jid, 

630 ) 

631 except PrivilegedIqError as exc: 

632 nested = exc.nested_error() 

633 # conflict this means the node already exists, we can ignore that 

634 if nested is not None and nested.condition != "conflict": 

635 log.exception( 

636 "Could not create the MDS node of %s", user_jid, exc_info=exc 

637 ) 

638 except Exception as e: 

639 log.exception( 

640 "Error while trying to create to the MDS node of %s", 

641 user_jid, 

642 exc_info=e, 

643 ) 

644 

645 iq_affiliation = Iq(sto=user_jid.bare, sfrom=user_jid, stype="set") 

646 iq_affiliation["pubsub_owner"]["affiliations"]["node"] = self.plugin[ 

647 "xep_0490" 

648 ].stanza.NS 

649 

650 aff = OwnerAffiliation() 

651 aff["jid"] = self.boundjid.bare 

652 aff["affiliation"] = "member" 

653 iq_affiliation["pubsub_owner"]["affiliations"].append(aff) 

654 

655 try: 

656 await self.plugin["xep_0356"].send_privileged_iq(iq_affiliation) 

657 except PermissionError: 

658 log.warning( 

659 "IQ privileges not granted for pubsub#owner namespace, we cannot " 

660 "listen to the MDS events of %s", 

661 user_jid, 

662 ) 

663 except Exception as e: 

664 log.exception( 

665 "Error while trying to subscribe to the MDS node of %s", 

666 user_jid, 

667 exc_info=e, 

668 ) 

669 

670 async def login_wrap(self, session: AnySession) -> str: 

671 session.send_gateway_status("Logging in…", show="dnd") 

672 session.is_logging_in = True 

673 try: 

674 status = await session.login() 

675 except Exception as e: 

676 log.warning("Login problem for %s", session.user_jid, exc_info=e) 

677 session.send_gateway_status(f"Could not login: {e}", show="dnd") 

678 msg = ( 

679 "You are not connected to this gateway! " 

680 f"Maybe this message will tell you why: {e}" 

681 ) 

682 session.send_gateway_message(msg) 

683 session.logged = False 

684 session.send_gateway_status("Login failed", show="dnd") 

685 return msg 

686 

687 log.info("Login success for %s", session.user_jid) 

688 session.logged = True 

689 session.send_gateway_status("Syncing contacts…", show="dnd") 

690 with self.store.session() as orm: 

691 await session.contacts._fill(orm) 

692 if not (r := session.contacts.ready).done(): 

693 r.set_result(True) 

694 if self.GROUPS: 

695 session.send_gateway_status("Syncing groups…", show="dnd") 

696 await session.bookmarks.fill() 

697 if not (r := session.bookmarks.ready).done(): 

698 r.set_result(True) 

699 self.send_presence(pto=session.user.jid.bare, ptype="probe") 

700 if status is None: 

701 status = "Logged in" 

702 session.send_gateway_status(status, show="chat") 

703 

704 if session.user.preferences.get("sync_avatar", False): 

705 session.create_task(self.fetch_user_avatar(session)) 

706 else: 

707 with self.store.session(expire_on_commit=False) as orm: 

708 session.user.avatar_hash = None 

709 orm.add(session.user) 

710 orm.commit() 

711 return status 

712 

713 async def fetch_user_avatar(self, session: AnySession) -> None: 

714 try: 

715 iq = await self.xmpp.plugin["xep_0060"].get_items( 

716 session.user_jid.bare, 

717 self.xmpp.plugin["xep_0084"].stanza.MetaData.namespace, 

718 ifrom=self.boundjid.bare, 

719 ) 

720 except IqTimeout: 

721 self.log.warning("Iq timeout trying to fetch user avatar") 

722 return 

723 except IqError as e: 

724 self.log.debug("Iq error when trying to fetch user avatar: %s", e) 

725 if e.condition == "item-not-found": 

726 try: 

727 await session.on_avatar(None, None, None, None, None) 

728 except NotImplementedError: 

729 pass 

730 else: 

731 with self.store.session(expire_on_commit=False) as orm: 

732 session.user.avatar_hash = None 

733 orm.add(session.user) 

734 orm.commit() 

735 return 

736 await self.__dispatcher.on_avatar_metadata_info( 

737 session, iq["pubsub"]["items"]["item"]["avatar_metadata"]["info"] 

738 ) 

739 

740 def _send( 

741 self, 

742 stanza: MessageOrPresenceTypeVar, 

743 **send_kwargs: Any, # noqa:ANN401 

744 ) -> MessageOrPresenceTypeVar: 

745 stanza.set_from(self.boundjid.bare) 

746 if mto := send_kwargs.get("mto"): 

747 stanza.set_to(mto) 

748 stanza.send() 

749 return stanza 

750 

751 def raise_if_not_allowed_jid(self, jid: JID) -> None: 

752 if not self.jid_validator.match(jid.bare): 

753 raise XMPPError( 

754 condition="not-allowed", 

755 text="Your account is not allowed to use this gateway. " 

756 "The admin controls that with the USER_JID_VALIDATOR option.", 

757 ) 

758 

759 def send_raw(self, data: str | bytes) -> None: 

760 # overridden from XMLStream to strip base64-encoded data from the logs 

761 # to make them more readable. 

762 if log.isEnabledFor(level=logging.DEBUG): 

763 stripped = copy(data) if isinstance(data, str) else data.decode("utf-8") 

764 # there is probably a way to do that in a single RE, 

765 # but since it's only for debugging, the perf penalty 

766 # does not matter much 

767 for el in LOG_STRIP_ELEMENTS: 

768 stripped = re.sub( 

769 f"(<{el}.*?>)(.*)(</{el}>)", 

770 "\1[STRIPPED]\3", 

771 stripped, 

772 flags=re.DOTALL | re.IGNORECASE, 

773 ) 

774 log.debug("SEND: %s", stripped) 

775 if not self.transport: 

776 raise NotConnectedError() 

777 if isinstance(data, str): 

778 data = data.encode("utf-8") 

779 self.transport.write(data) 

780 

781 def get_session_from_jid(self, j: JID) -> AnySession | None: 

782 try: 

783 return self.session_cls.from_jid(j) 

784 except XMPPError: 

785 return None 

786 

787 def exception(self, exception: Exception) -> None: 

788 # """ 

789 # Called when a task created by slixmpp's internal (eg, on slix events) raises an Exception. 

790 # 

791 # Stop the event loop and exit on unhandled exception. 

792 # 

793 # The default :class:`slixmpp.basexmpp.BaseXMPP` behaviour is just to 

794 # log the exception, but we want to avoid undefined behaviour. 

795 # 

796 # :param exception: An unhandled :class:`Exception` object. 

797 # """ 

798 if isinstance(exception, IqError): 

799 iq = exception.iq 

800 log.error("%s: %s", iq["error"]["condition"], iq["error"]["text"]) 

801 log.warning("You should catch IqError exceptions") 

802 elif isinstance(exception, IqTimeout): 

803 iq = exception.iq 

804 log.error("Request timed out: %s", iq) 

805 log.warning("You should catch IqTimeout exceptions") 

806 elif isinstance(exception, SyntaxError): 

807 # Hide stream parsing errors that occur when the 

808 # stream is disconnected (they've been handled, we 

809 # don't need to make a mess in the logs). 

810 pass 

811 else: 

812 if exception: 

813 log.exception(exception) 

814 self.loop.stop() 

815 sys.exit(1) 

816 

817 async def make_registration_form( 

818 self, _jid: JID, _node: str, _ifrom: JID, iq: Iq 

819 ) -> Iq: 

820 self.raise_if_not_allowed_jid(iq.get_from()) 

821 reg = iq["register"] 

822 with self.store.session() as orm: 

823 user = ( 

824 orm.query(GatewayUser).filter_by(jid=iq.get_from().bare).one_or_none() 

825 ) 

826 log.debug("User found: %s", user) 

827 

828 form = reg["form"] 

829 form.add_field( 

830 "FORM_TYPE", 

831 ftype="hidden", 

832 value="jabber:iq:register", 

833 ) 

834 form["title"] = f"Registration to '{self.COMPONENT_NAME}'" 

835 form["instructions"] = self.REGISTRATION_INSTRUCTIONS 

836 

837 if user is not None: 

838 reg["registered"] = False 

839 form.add_field( 

840 "remove", 

841 label="Remove my registration", 

842 required=True, 

843 ftype="boolean", 

844 value=False, 

845 ) 

846 

847 for field in self.REGISTRATION_FIELDS: 

848 if field.var in reg.interfaces: 

849 val = None if user is None else user.get(field.var) 

850 if val is None: 

851 reg.add_field(field.var) 

852 else: 

853 reg[field.var] = val 

854 

855 reg["instructions"] = self.REGISTRATION_INSTRUCTIONS 

856 

857 for field in self.REGISTRATION_FIELDS: 

858 form.add_field( 

859 field.var, 

860 label=field.label, 

861 required=field.required, 

862 ftype=field.type, 

863 options=field.options, 

864 value=field.value if user is None else user.get(field.var, field.value), 

865 ) 

866 

867 reply = iq.reply() 

868 reply.set_payload(reg) 

869 return reply # type:ignore[no-any-return] 

870 

871 async def user_prevalidate( 

872 self, ifrom: JID, form_dict: dict[str, str | None] 

873 ) -> JSONSerializable | None: 

874 # Pre validate a registration form using the content of self.REGISTRATION_FIELDS 

875 # before passing it to the plugin custom validation logic. 

876 for field in self.REGISTRATION_FIELDS: 

877 if field.required and not form_dict.get(field.var): 

878 raise ValueError(f"Missing field: '{field.label}'") 

879 

880 return await self.validate(ifrom, form_dict) 

881 

882 @abc.abstractmethod 

883 async def validate( 

884 self, user_jid: JID, registration_form: dict[str, str | None] 

885 ) -> JSONSerializable | None: 

886 """ 

887 Validate a user's initial registration form. 

888 

889 Should raise the appropriate :class:`slixmpp.exceptions.XMPPError` 

890 if the registration does not allow to continue the registration process. 

891 

892 If :py:attr:`REGISTRATION_TYPE` is a 

893 :attr:`.RegistrationType.SINGLE_STEP_FORM`, 

894 this method should raise something if it wasn't possible to successfully 

895 log in to the legacy service with the registration form content. 

896 

897 It is also used for other types of :py:attr:`REGISTRATION_TYPE` too, since 

898 the first step is always a form. If :attr:`.REGISTRATION_FIELDS` is an 

899 empty list (ie, it declares no :class:`.FormField`), the "form" is 

900 effectively a confirmation dialog displaying 

901 :attr:`.REGISTRATION_INSTRUCTIONS`. 

902 

903 :param user_jid: JID of the user that has just registered 

904 :param registration_form: A dict where keys are the :attr:`.FormField.var` attributes 

905 of the :attr:`.BaseGateway.REGISTRATION_FIELDS` iterable. 

906 This dict can be modified and will be accessible as the ``legacy_module_data`` 

907 of the 

908 

909 :return : A dict that will be stored as the persistent "legacy_module_data" 

910 for this user. If you don't return anything here, the whole registration_form 

911 content will be stored. 

912 """ 

913 raise NotImplementedError 

914 

915 async def validate_two_factor_code( 

916 self, user: GatewayUser, code: str 

917 ) -> JSONSerializable | None: 

918 """ 

919 Called when the user enters their 2FA code. 

920 

921 Should raise the appropriate :class:`slixmpp.exceptions.XMPPError` 

922 if the login fails, and return successfully otherwise. 

923 

924 Only used when :attr:`REGISTRATION_TYPE` is 

925 :attr:`.RegistrationType.TWO_FACTOR_CODE`. 

926 

927 :param user: The :class:`.GatewayUser` whose registration is pending 

928 Use their :attr:`.GatewayUser.bare_jid` and/or 

929 :attr:`.registration_form` attributes to get what you need. 

930 :param code: The code they entered, either via "chatbot" message or 

931 adhoc command 

932 

933 :return : A dict which keys and values will be added to the persistent "legacy_module_data" 

934 for this user. 

935 """ 

936 raise NotImplementedError 

937 

938 async def get_qr_text(self, user: GatewayUser) -> str: 

939 """ 

940 This is where slidge gets the QR code content for the QR-based 

941 registration process. It will turn it into a QR code image and send it 

942 to the not-yet-fully-registered :class:`.GatewayUser`. 

943 

944 Only used in when :attr:`BaseGateway.REGISTRATION_TYPE` is 

945 :attr:`.RegistrationType.QRCODE`. 

946 

947 :param user: The :class:`.GatewayUser` whose registration is pending 

948 Use their :attr:`.GatewayUser.bare_jid` and/or 

949 :attr:`.registration_form` attributes to get what you need. 

950 """ 

951 raise NotImplementedError 

952 

953 async def confirm_qr( 

954 self, 

955 user_bare_jid: str, 

956 exception: Exception | None = None, 

957 legacy_data: JSONSerializable | None = None, 

958 ) -> None: 

959 """ 

960 This method is meant to be called to finalize QR code-based registration 

961 flows, once the legacy service confirms the QR flashing. 

962 

963 Only used in when :attr:`BaseGateway.REGISTRATION_TYPE` is 

964 :attr:`.RegistrationType.QRCODE`. 

965 

966 :param user_bare_jid: The bare JID of the almost-registered 

967 :class:`GatewayUser` instance 

968 :param exception: Optionally, an XMPPError to be raised to **not** confirm 

969 QR code flashing. 

970 :param legacy_data: dict which keys and values will be added to the persistent 

971 "legacy_module_data" for this user. 

972 """ 

973 fut = self.qr_pending_registrations[user_bare_jid] 

974 if exception is None: 

975 fut.set_result(legacy_data) 

976 else: 

977 fut.set_exception(exception) 

978 

979 async def unregister_user( 

980 self, user: GatewayUser, msg: str = "You unregistered from this gateway." 

981 ) -> None: 

982 self.send_presence(pshow="dnd", pstatus=msg, pto=user.jid) 

983 await self.xmpp.plugin["xep_0077"].api["user_remove"](None, None, user.jid) # type:ignore[call-arg] 

984 await self.xmpp.session_cls.kill_by_jid(user.jid) 

985 

986 async def input( 

987 self, 

988 jid: JID, 

989 text: str | None = None, 

990 mtype: MessageTypes = "chat", 

991 **input_kwargs: Any, # noqa:ANN401 

992 ) -> str: 

993 """ 

994 Request arbitrary user input using a simple chat message, and await the result. 

995 

996 You shouldn't need to call this directly bust instead use 

997 :meth:`.BaseSession.input` to directly target a user. 

998 

999 :param jid: The JID we want input from 

1000 :param text: A prompt to display for the user 

1001 :param mtype: Message type 

1002 :return: The user's reply 

1003 """ 

1004 return await self.__chat_commands_handler.input( 

1005 jid, text, mtype=mtype, **input_kwargs 

1006 ) 

1007 

1008 async def send_qr( 

1009 self, 

1010 text: str, 

1011 **msg_kwargs: Any, # noqa:ANN401 

1012 ) -> str | None: 

1013 """ 

1014 Sends a QR Code to a JID 

1015 

1016 You shouldn't need to call directly bust instead use 

1017 :meth:`.BaseSession.send_qr` to directly target a user. 

1018 

1019 :param text: The text that will be converted to a QR Code 

1020 :param msg_kwargs: Optional additional arguments to pass to 

1021 :meth:`.BaseGateway.send_file`, such as the recipient of the QR, 

1022 code 

1023 """ 

1024 try: 

1025 import qrcode 

1026 except ImportError: 

1027 log.error("Slidge needs the [qr] extra to be able to generate QR codes") 

1028 raise 

1029 qr = qrcode.make(text) 

1030 with tempfile.NamedTemporaryFile(suffix=".png") as f: 

1031 qr.save(f.name) 

1032 url, _msgs = await self.send_file(Path(f.name), **msg_kwargs) 

1033 return url 

1034 

1035 def shutdown(self) -> list[asyncio.Task[None]]: 

1036 # """ 

1037 # Called by the slidge entrypoint on normal exit. 

1038 # 

1039 # Sends offline presences from all contacts of all user sessions and from 

1040 # the gateway component itself. 

1041 # No need to call this manually, :func:`slidge.__main__.main` should take care of it. 

1042 # """ 

1043 log.debug("Shutting down") 

1044 tasks = [] 

1045 with self.store.session() as orm: 

1046 for user in orm.query(GatewayUser).all(): 

1047 tasks.append(self.session_cls.from_jid(user.jid).shutdown()) 

1048 self.send_presence(ptype="unavailable", pto=user.jid) 

1049 return tasks 

1050 

1051 

1052SLIXMPP_PLUGINS = [ 

1053 "xep_0030", # Service discovery 

1054 "xep_0045", # Multi-User Chat 

1055 "xep_0050", # Adhoc commands 

1056 "xep_0054", # VCard-temp (for MUC avatars) 

1057 "xep_0055", # Jabber search 

1058 "xep_0059", # Result Set Management 

1059 "xep_0066", # Out of Band Data 

1060 "xep_0071", # XHTML-IM (for stickers and custom emojis maybe later) 

1061 "xep_0077", # In-band registration 

1062 "xep_0084", # User Avatar 

1063 "xep_0085", # Chat state notifications 

1064 "xep_0100", # Gateway interaction 

1065 "xep_0106", # JID Escaping 

1066 "xep_0115", # Entity capabilities 

1067 "xep_0122", # Data Forms Validation 

1068 "xep_0128", # Service Discovery Extensions 

1069 "xep_0153", # vCard-Based Avatars (for MUC avatars) 

1070 "xep_0172", # User nickname 

1071 "xep_0184", # Message Delivery Receipts 

1072 "xep_0199", # XMPP Ping 

1073 "xep_0221", # Data Forms Media Element 

1074 "xep_0231", # Bits of Binary (for stickers and custom emojis maybe later) 

1075 "xep_0249", # Direct MUC Invitations 

1076 "xep_0264", # Jingle Content Thumbnails 

1077 "xep_0280", # Carbons 

1078 "xep_0292_provider", # VCard4 

1079 "xep_0308", # Last message correction 

1080 "xep_0313", # Message Archive Management 

1081 "xep_0317", # Hats 

1082 "xep_0319", # Last User Interaction in Presence 

1083 "xep_0333", # Chat markers 

1084 "xep_0334", # Message Processing Hints 

1085 "xep_0356", # Privileged Entity 

1086 "xep_0363", # HTTP file upload 

1087 "xep_0385", # Stateless in-line media sharing 

1088 "xep_0402", # PEP Native Bookmarks 

1089 "xep_0421", # Anonymous unique occupant identifiers for MUCs 

1090 "xep_0424", # Message retraction 

1091 "xep_0425", # Message moderation 

1092 "xep_0444", # Message reactions 

1093 "xep_0447", # Stateless File Sharing 

1094 "xep_0449", # Stickers 

1095 "xep_0461", # Message replies 

1096 "xep_0462", # Pubsub Type Filtering 

1097 "xep_0463", # MUC Affiliation Versioning 

1098 "xep_0469", # Bookmark Pinning 

1099 "xep_0490", # Message Displayed Synchronization 

1100 "xep_0492", # Chat Notification Settings 

1101 # "xep_0503", # Server-side spaces 

1102 "xep_0511", # Link Metadata 

1103] 

1104 

1105LOG_STRIP_ELEMENTS = ["data", "binval"] 

1106 

1107log = logging.getLogger(__name__)