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

529 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-10 04:45 +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 ( 

14 Awaitable, 

15 Callable, 

16 Iterable, 

17 Sequence, 

18) 

19from copy import copy 

20from datetime import UTC, datetime, timedelta 

21from pathlib import Path 

22from typing import ( 

23 Any, 

24 ClassVar, 

25 Concatenate, 

26 Generic, 

27 ParamSpec, 

28 TypeVar, 

29) 

30 

31import aiohttp 

32from slixmpp import JID, ComponentXMPP, Iq 

33from slixmpp.exceptions import IqError, IqTimeout, XMPPError 

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

35from slixmpp.plugins.xep_0060.stanza import OwnerAffiliation 

36from slixmpp.plugins.xep_0356.privilege import PrivilegedIqError 

37from slixmpp.types import MessageTypes 

38from slixmpp.xmlstream.xmlstream import NotConnectedError 

39from sqlalchemy.orm import Session as OrmSession 

40 

41import slidge.command.categories 

42from slidge.command import BUILTIN_COMMANDS 

43from slidge.command.adhoc import AdhocProvider 

44from slidge.command.admin import Exec 

45from slidge.command.base import ( 

46 Command, 

47 CommandBase, 

48 ContactCommand, 

49 FormField, 

50 MUCCommand, 

51) 

52from slidge.command.chat_command import ChatCommandProvider 

53from slidge.command.register import AltRegistrationFlow, Register, RegistrationType 

54from slidge.contact import LegacyContact 

55from slidge.core import config 

56from slidge.core.attachment_upload import AttachmentUploader 

57from slidge.core.dispatcher.session_dispatcher import SessionDispatcher 

58from slidge.core.mixins.avatar import convert_avatar 

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

60from slidge.core.pubsub import PubSubComponent 

61from slidge.db import GatewayUser, SlidgeStore 

62from slidge.db.avatar import CachedAvatar, avatar_cache 

63from slidge.db.meta import JSONSerializable 

64from slidge.db.models import Attachment 

65from slidge.group import LegacyMUC 

66from slidge.slixfix.delivery_receipt import DeliveryReceipt 

67from slidge.slixfix.roster import RosterBackend 

68from slidge.util.types import ( 

69 AnySession, 

70 Avatar, 

71 MessageOrPresenceTypeVar, 

72 RegistrationValidationCoroutine, 

73 SessionType_co, 

74) 

75from slidge.util.util import derive_wired_class 

76 

77T = TypeVar("T") 

78P = ParamSpec("P") 

79 

80 

81class BaseGateway( 

82 ComponentXMPP, 

83 InviteMixin, 

84 ContentMessageMixin, 

85 abc.ABC, 

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

87): 

88 """ 

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

90 

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

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

93 ``.xmpp`` attribute. 

94 

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

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

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

98 

99 Abstract methods related to the registration process must be overriden 

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

101 

102 - :meth:`.validate` 

103 - :meth:`.validate_two_factor_code` 

104 - :meth:`.get_qr_text` 

105 - :meth:`.confirm_qr` 

106 

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

108 :attr:`REGISTRATION_TYPE`. 

109 

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

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

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

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

114 `mto` parameter. 

115 

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

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

118 

119 .. code-block:: python 

120 

121 self.send_presence( 

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

123 pto="someonwelse@anotherexample.com", 

124 ) 

125 

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

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

128 as sending messages, or displaying a custom status. 

129 

130 """ 

131 

132 COMPONENT_NAME: str = NotImplemented 

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

134 COMPONENT_TYPE: str = "" 

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

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

137 """ 

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

139 """ 

140 

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

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

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

144 ] 

145 """ 

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

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

148 """ 

149 REGISTRATION_INSTRUCTIONS: str = "Enter your credentials" 

150 """ 

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

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

153 """ 

154 REGISTRATION_TYPE: RegistrationType = RegistrationType.SINGLE_STEP_FORM 

155 """ 

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

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

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

159 once per user (unless they unregister). 

160 

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

162 presented to the user. 

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

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

165 """ 

166 

167 REGISTRATION_2FA_TITLE = "Enter your 2FA code" 

168 REGISTRATION_2FA_INSTRUCTIONS = ( 

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

170 ) 

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

172 

173 ALTERNATIVE_REGISTRATION_FLOWS: ClassVar[list[AltRegistrationFlow]] = [] 

174 

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

176 FormField( 

177 var="sync_presence", 

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

179 value="true", 

180 required=True, 

181 type="boolean", 

182 ), 

183 FormField( 

184 var="sync_avatar", 

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

186 value="true", 

187 required=True, 

188 type="boolean", 

189 ), 

190 FormField( 

191 var="always_invite_when_adding_bookmarks", 

192 label="Always send invitations to join MUCs.", 

193 value="true", 

194 required=True, 

195 type="boolean", 

196 ), 

197 FormField( 

198 var="last_seen_fallback", 

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

200 value="true", 

201 required=True, 

202 type="boolean", 

203 ), 

204 FormField( 

205 var="roster_push", 

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

207 value="true", 

208 required=True, 

209 type="boolean", 

210 ), 

211 FormField( 

212 var="reaction_fallback", 

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

214 value="false", 

215 required=True, 

216 type="boolean", 

217 ), 

218 ] 

219 

220 ROSTER_GROUP: str = "slidge" 

221 """ 

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

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

224 """ 

225 WELCOME_MESSAGE = ( 

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

227 "or just start messaging away!" 

228 ) 

229 """ 

230 A welcome message displayed to users on registration. 

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

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

233 incoming messages from components. 

234 """ 

235 

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

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

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

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

240 ] 

241 """ 

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

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

244 their usernames, eg their phone number. 

245 

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

247 (restricted to registered users). 

248 

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

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

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

252 """ 

253 SEARCH_TITLE: str = "Search for legacy contacts" 

254 """ 

255 Title of the search form. 

256 """ 

257 SEARCH_INSTRUCTIONS: str = "" 

258 """ 

259 Instructions of the search form. 

260 """ 

261 

262 MARK_ALL_MESSAGES = False 

263 """ 

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

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

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

267 """ 

268 

269 PROPER_RECEIPTS = False 

270 """ 

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

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

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

274 """ 

275 

276 GROUPS = False 

277 """ 

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

279 """ 

280 SPACES = False 

281 """ 

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

283 """ 

284 

285 mtype: MessageTypes = "chat" 

286 is_group = False 

287 _can_send_carbon = False 

288 store: SlidgeStore 

289 

290 session_cls: type[SessionType_co] 

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

292 

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

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

295 """ 

296 

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

298 super().__init_subclass__(**kwargs) 

299 derive_wired_class(cls, BaseGateway, "session_cls") 

300 

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

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

303 

304 Subclasses may override this with additional custom commands. 

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

306 """ 

307 

308 http: aiohttp.ClientSession 

309 avatar: CachedAvatar | None = None 

310 

311 def __init__(self) -> None: 

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

313 raise RuntimeError( 

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

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

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

317 ) 

318 if config.COMPONENT_NAME: 

319 self.COMPONENT_NAME = config.COMPONENT_NAME 

320 if config.WELCOME_MESSAGE: 

321 self.WELCOME_MESSAGE = config.WELCOME_MESSAGE 

322 self.log = log 

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

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

325 self.xmpp = self 

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

327 super().__init__( 

328 config.JID, 

329 config.SECRET, 

330 config.SERVER, 

331 config.PORT, 

332 plugin_whitelist=SLIXMPP_PLUGINS, 

333 plugin_config={ 

334 "xep_0077": { 

335 "form_fields": None, 

336 "form_instructions": self.REGISTRATION_INSTRUCTIONS, 

337 "enable_subscription": self.REGISTRATION_TYPE 

338 == RegistrationType.SINGLE_STEP_FORM, 

339 }, 

340 "xep_0100": { 

341 "component_name": self.COMPONENT_NAME, 

342 "type": self.COMPONENT_TYPE, 

343 }, 

344 "xep_0184": { 

345 "auto_ack": False, 

346 "auto_request": False, 

347 }, 

348 "xep_0363": { 

349 "upload_service": config.UPLOAD_SERVICE, 

350 }, 

351 }, 

352 fix_error_ns=True, 

353 ) 

354 self.loop.set_exception_handler(self.__exception_handler) 

355 self.loop.create_task(self.__set_http(), name="set http client") 

356 self.has_crashed: bool = False 

357 self.use_origin_id = False 

358 

359 if config.USER_JID_VALIDATOR is None: 

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

361 log.info( 

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

363 config.USER_JID_VALIDATOR, 

364 ) 

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

366 self.qr_pending_registrations = dict[ 

367 str, asyncio.Future[JSONSerializable | None] 

368 ]() 

369 

370 self.register_plugins() 

371 self.__setup_session_cls() 

372 

373 self.get_session_from_stanza = self.session_cls.from_stanza 

374 self.get_session_from_user = self.session_cls.from_user 

375 

376 self.__register_slixmpp_events() 

377 self.__register_slixmpp_api() 

378 self.roster.set_backend(RosterBackend(self)) 

379 

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

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

382 self.delivery_receipt = DeliveryReceipt(self) 

383 

384 # with this we receive user avatar updates 

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

386 

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

388 

389 if self.GROUPS: 

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

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

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

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

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

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

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

397 category="conference", 

398 name=self.COMPONENT_NAME, 

399 itype="text", 

400 jid=self.boundjid, 

401 ) 

402 if self.SPACES: 

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

404 

405 self.__adhoc_handler = AdhocProvider(self) 

406 self.__chat_commands_handler = ChatCommandProvider(self) 

407 

408 self.__dispatcher = SessionDispatcher(self) 

409 

410 self.__register_commands() 

411 

412 def __setup_session_cls(self) -> None: 

413 contact_cls = self.session_cls.roster_cls.contact_cls 

414 

415 if contact_cls.REACTIONS_SINGLE_EMOJI: 

416 form = Form() 

417 form["type"] = "result" 

418 form.add_field( 

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

420 ) 

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

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

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

424 

425 self.session_cls.xmpp = self 

426 

427 @property 

428 def _uploader(self) -> AttachmentUploader: 

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

430 return AttachmentUploader(self, None) 

431 

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

433 await self.session_cls.kill_by_jid(jid) 

434 

435 async def __set_http(self) -> None: 

436 self.http = aiohttp.ClientSession() 

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

438 return 

439 avatar_cache.http = self.http 

440 

441 def __register_commands(self) -> None: 

442 for cls in self.COMMANDS: 

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

444 raise TypeError( 

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

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

447 ) 

448 if issubclass(cls, ContactCommand): 

449 LegacyContact.commands[cls.NODE] = cls 

450 LegacyContact.commands_chat[cls.CHAT_COMMAND] = cls 

451 continue 

452 if issubclass(cls, MUCCommand): 

453 LegacyMUC.commands[cls.NODE] = cls 

454 LegacyMUC.commands_chat[cls.CHAT_COMMAND] = cls 

455 continue 

456 if not issubclass(cls, Command): 

457 raise TypeError( 

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

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

460 ) 

461 if cls is Exec: 

462 if config.DEV_MODE: 

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

464 else: 

465 continue 

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

467 continue 

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

469 continue 

470 c = cls(self) 

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

472 self.__adhoc_handler.register(c) 

473 self.__chat_commands_handler.register(c) 

474 

475 for i, alt in enumerate(self.ALTERNATIVE_REGISTRATION_FLOWS): 

476 

477 class AltRegister(Register): 

478 NAME = alt.title 

479 NODE = f"https://slidge.im/register/alt{i}" 

480 CHAT_COMMAND = f"register-{i}" 

481 _instructions = alt.instructions 

482 _type = alt.type 

483 _fields = alt.fields 

484 _validate = staticmethod(alt.validate) if alt.validate else None 

485 

486 inst = AltRegister(self) 

487 log.debug("Registering alternative registration flow: %s", alt.title) 

488 self.__adhoc_handler.register(inst) 

489 self.__chat_commands_handler.register(inst) 

490 

491 def __exception_handler( 

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

493 ) -> None: 

494 """ 

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

496 

497 :param loop: 

498 :param context: 

499 :return: 

500 """ 

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

502 exc = context.get("exception") 

503 if exc is None: 

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

505 elif isinstance(exc, SystemExit): 

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

507 else: 

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

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

510 self.has_crashed = True 

511 loop.stop() 

512 

513 def __register_slixmpp_events(self) -> None: 

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

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

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

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

518 self.del_event_handler( 

519 "roster_subscription_request", self._handle_new_subscription 

520 ) 

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

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

523 

524 def __register_slixmpp_api(self) -> None: 

525 def with_session( 

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

527 ) -> Callable[P, T]: 

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

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

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

531 if commit: 

532 orm.commit() 

533 return res 

534 

535 return wrapped 

536 

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

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

539 ) 

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

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

542 ) 

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

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

545 ) 

546 

547 @property # type:ignore[override] 

548 def jid(self) -> JID: 

549 # Override to avoid slixmpp deprecation warnings. 

550 return self.boundjid 

551 

552 @jid.setter 

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

554 raise RuntimeError 

555 

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

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

558 

559 await self.__setup_attachments() 

560 

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

562 disco = self.plugin["xep_0030"] 

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

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

565 

566 if self.COMPONENT_AVATAR is not None: 

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

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

569 assert avatar is not None 

570 try: 

571 cached_avatar = await avatar_cache.get(avatar) 

572 except Exception as e: 

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

574 cached_avatar = None 

575 else: 

576 assert cached_avatar is not None 

577 self.avatar = cached_avatar 

578 else: 

579 cached_avatar = None 

580 

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

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

583 for user in users: 

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

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

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

587 # as last resort. 

588 try: 

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

590 await self.__add_component_to_mds_whitelist(user.jid) 

591 except (IqError, IqTimeout) as e: 

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

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

594 log.warning( 

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

596 user, 

597 exc_info=e, 

598 ) 

599 continue 

600 session = self.session_cls.from_user(user) 

601 session.create_task(self.login_wrap(session), name=f"login wrap of {user}") 

602 if cached_avatar is not None: 

603 await self.pubsub.broadcast_avatar( 

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

605 ) 

606 

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

608 

609 async def __setup_attachments(self) -> None: 

610 expire_coro = self.__expire_attachments_upload 

611 if config.NO_UPLOAD_PATH: 

612 if config.NO_UPLOAD_URL_PREFIX is None: 

613 raise RuntimeError( 

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

615 ) 

616 expire_coro = self.__expire_attachments_no_upload 

617 elif not config.UPLOAD_SERVICE: 

618 try: 

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

620 self.infer_real_domain() 

621 ) 

622 except XMPPError: 

623 info_iq = None 

624 log.exception( 

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

626 "Attachments to XMPP will not work. " 

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

628 ) 

629 if info_iq is None: 

630 if self.REGISTRATION_TYPE == RegistrationType.QRCODE: 

631 log.warning( 

632 "No method was configured for attachment and slidge " 

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

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

635 "QR-code based registration flow." 

636 ) 

637 if not config.USE_ATTACHMENT_ORIGINAL_URLS: 

638 log.warning( 

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

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

641 "networks, especially for the E2EE attachments." 

642 ) 

643 config.USE_ATTACHMENT_ORIGINAL_URLS = True 

644 else: 

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

646 config.UPLOAD_SERVICE = info_iq["from"] 

647 

648 self.__expire_attachments_task = self.loop.create_task( 

649 _loop(expire_coro, 3600 * 24) 

650 ) 

651 

652 async def __expire_attachments_no_upload(self) -> None: 

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

654 attachments = self.store.attachments.get_all(orm) 

655 self.__expire_attachments_in_store( 

656 await asyncio.to_thread(self.__rm_local_attachments, attachments) 

657 ) 

658 

659 def __rm_local_attachments(self, attachments: list[Attachment]) -> list[int]: 

660 to_remove = [] 

661 cutoff = datetime.now(tz=UTC) - timedelta( 

662 days=config.NO_UPLOAD_MAX_DAYS or config.MAM_MAX_DAYS 

663 ) 

664 

665 for attachment in attachments: 

666 path = attachment.local_path 

667 try: 

668 created = datetime.fromtimestamp(path.stat().st_ctime, tz=UTC) 

669 except OSError: 

670 self.log.debug( 

671 "mtime of %s could not be determined, clearing up the row", 

672 attachment, 

673 ) 

674 to_remove.append(attachment.id) 

675 else: 

676 if created > cutoff: 

677 continue 

678 to_remove.append(attachment.id) 

679 self.log.debug("%s is too old", attachment) 

680 _safe_rm_parent(path) 

681 to_remove.append(attachment.id) 

682 

683 return to_remove 

684 

685 async def __expire_attachments_upload(self) -> None: 

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

687 attachments = self.store.attachments.get_all(orm) 

688 to_remove = [] 

689 for attachment in attachments: 

690 async with self.http.head(attachment.url) as resp: 

691 if not resp.ok: 

692 self.log.debug("%s is not reachable anymore", attachment) 

693 to_remove.append(attachment.id) 

694 self.__expire_attachments_in_store(to_remove) 

695 

696 def __expire_attachments_in_store(self, to_remove: list[int]) -> None: 

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

698 self.store.attachments.remove(orm, to_remove) 

699 orm.commit() 

700 

701 def infer_real_domain(self) -> JID: 

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

703 

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

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

706 # MDS node so we receive MDS events 

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

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

709 

710 try: 

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

712 except PermissionError: 

713 log.warning( 

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

715 "create the MDS node of %s", 

716 user_jid, 

717 ) 

718 except PrivilegedIqError as exc: 

719 nested = exc.nested_error() 

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

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

722 log.exception( 

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

724 ) 

725 except Exception as e: 

726 log.exception( 

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

728 user_jid, 

729 exc_info=e, 

730 ) 

731 

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

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

734 "xep_0490" 

735 ].stanza.NS 

736 

737 aff = OwnerAffiliation() 

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

739 aff["affiliation"] = "member" 

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

741 

742 try: 

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

744 except PermissionError: 

745 log.warning( 

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

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

748 user_jid, 

749 ) 

750 except Exception as e: 

751 log.exception( 

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

753 user_jid, 

754 exc_info=e, 

755 ) 

756 

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

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

759 session.is_logging_in = True 

760 try: 

761 status = await session.login() 

762 except Exception as e: 

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

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

765 msg = ( 

766 "You are not connected to this gateway! " 

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

768 ) 

769 session.send_gateway_message(msg) 

770 session.logged = False 

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

772 return msg 

773 

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

775 session.logged = True 

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

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

778 await session.contacts._fill(orm) 

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

780 r.set_result(True) 

781 if self.GROUPS: 

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

783 await session.bookmarks.fill() 

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

785 r.set_result(True) 

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

787 if status is None: 

788 status = "Logged in" 

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

790 

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

792 session.create_task( 

793 self.fetch_user_avatar(session), name=f"fetch avatar of {session.user}" 

794 ) 

795 else: 

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

797 session.user.avatar_hash = None 

798 orm.add(session.user) 

799 orm.commit() 

800 return status 

801 

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

803 try: 

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

805 session.user_jid.bare, 

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

807 ifrom=self.boundjid.bare, 

808 ) 

809 except IqTimeout: 

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

811 return 

812 except IqError as e: 

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

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

815 try: 

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

817 except NotImplementedError: 

818 pass 

819 else: 

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

821 session.user.avatar_hash = None 

822 orm.add(session.user) 

823 orm.commit() 

824 return 

825 await self.__dispatcher.on_avatar_metadata_info( 

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

827 ) 

828 

829 def _send( 

830 self, 

831 stanza: MessageOrPresenceTypeVar, 

832 **send_kwargs: Any, # noqa:ANN401 

833 ) -> MessageOrPresenceTypeVar: 

834 stanza.set_from(self.boundjid.bare) 

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

836 stanza.set_to(mto) 

837 stanza.send() 

838 return stanza 

839 

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

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

842 raise XMPPError( 

843 condition="not-allowed", 

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

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

846 ) 

847 

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

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

850 # to make them more readable. 

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

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

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

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

855 # does not matter much 

856 for el in LOG_STRIP_ELEMENTS: 

857 stripped = re.sub( 

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

859 "\1[STRIPPED]\3", 

860 stripped, 

861 flags=re.DOTALL | re.IGNORECASE, 

862 ) 

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

864 if not self.transport: 

865 raise NotConnectedError() 

866 if isinstance(data, str): 

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

868 self.transport.write(data) 

869 

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

871 try: 

872 return self.session_cls.from_jid(j) 

873 except XMPPError: 

874 return None 

875 

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

877 # """ 

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

879 # 

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

881 # 

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

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

884 # 

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

886 # """ 

887 if isinstance(exception, IqError): 

888 iq = exception.iq 

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

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

891 elif isinstance(exception, IqTimeout): 

892 iq = exception.iq 

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

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

895 elif isinstance(exception, SyntaxError): 

896 # Hide stream parsing errors that occur when the 

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

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

899 pass 

900 else: 

901 if exception: 

902 log.exception(exception) 

903 self.loop.stop() 

904 sys.exit(1) 

905 

906 async def make_registration_form( 

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

908 ) -> Iq: 

909 self.raise_if_not_allowed_jid(iq.get_from()) 

910 reg = iq["register"] 

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

912 user = ( 

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

914 ) 

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

916 

917 form = reg["form"] 

918 form.add_field( 

919 "FORM_TYPE", 

920 ftype="hidden", 

921 value="jabber:iq:register", 

922 ) 

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

924 form["instructions"] = self.REGISTRATION_INSTRUCTIONS 

925 

926 if user is not None: 

927 reg["registered"] = False 

928 form.add_field( 

929 "remove", 

930 label="Remove my registration", 

931 required=True, 

932 ftype="boolean", 

933 value=False, 

934 ) 

935 

936 for field in self.REGISTRATION_FIELDS: 

937 if field.var in reg.interfaces: 

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

939 if val is None: 

940 reg.add_field(field.var) 

941 else: 

942 reg[field.var] = val 

943 

944 reg["instructions"] = self.REGISTRATION_INSTRUCTIONS 

945 

946 for field in self.REGISTRATION_FIELDS: 

947 form.add_field( 

948 field.var, 

949 label=field.label, 

950 required=field.required, 

951 ftype=field.type, 

952 options=field.options, 

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

954 ) 

955 

956 reply = iq.reply() 

957 reply.set_payload(reg) 

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

959 

960 async def user_prevalidate( 

961 self, 

962 ifrom: JID, 

963 form_dict: JSONSerializable, 

964 fields: Iterable[FormField] | None = None, 

965 validate: RegistrationValidationCoroutine | None = None, 

966 ) -> JSONSerializable | None: 

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

968 # before passing it to the plugin custom validation logic 

969 if fields is None: 

970 fields = self.REGISTRATION_FIELDS 

971 for field in fields: 

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

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

974 if validate: 

975 return await validate(ifrom, form_dict) 

976 else: 

977 return await self.validate(ifrom, form_dict) 

978 

979 @abc.abstractmethod 

980 async def validate( 

981 self, user_jid: JID, registration_form: JSONSerializable 

982 ) -> JSONSerializable | None: 

983 """ 

984 Validate a user's initial registration form. 

985 

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

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

988 

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

990 :attr:`.RegistrationType.SINGLE_STEP_FORM`, 

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

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

993 

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

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

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

997 effectively a confirmation dialog displaying 

998 :attr:`.REGISTRATION_INSTRUCTIONS`. 

999 

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

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

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

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

1004 of the 

1005 

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

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

1008 content will be stored. 

1009 """ 

1010 raise NotImplementedError 

1011 

1012 async def validate_two_factor_code( 

1013 self, user: GatewayUser, code: str 

1014 ) -> JSONSerializable | None: 

1015 """ 

1016 Called when the user enters their 2FA code. 

1017 

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

1019 if the login fails, and return successfully otherwise. 

1020 

1021 Only used when :attr:`REGISTRATION_TYPE` is 

1022 :attr:`.RegistrationType.TWO_FACTOR_CODE`. 

1023 

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

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

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

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

1028 adhoc command 

1029 

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

1031 for this user. 

1032 """ 

1033 raise NotImplementedError 

1034 

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

1036 """ 

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

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

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

1040 

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

1042 :attr:`.RegistrationType.QRCODE`. 

1043 

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

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

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

1047 """ 

1048 raise NotImplementedError 

1049 

1050 async def confirm_qr( 

1051 self, 

1052 user_bare_jid: str, 

1053 exception: Exception | None = None, 

1054 legacy_data: JSONSerializable | None = None, 

1055 ) -> None: 

1056 """ 

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

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

1059 

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

1061 :attr:`.RegistrationType.QRCODE`. 

1062 

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

1064 :class:`GatewayUser` instance 

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

1066 QR code flashing. 

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

1068 "legacy_module_data" for this user. 

1069 """ 

1070 fut = self.qr_pending_registrations[user_bare_jid] 

1071 if exception is None: 

1072 fut.set_result(legacy_data) 

1073 else: 

1074 fut.set_exception(exception) 

1075 

1076 async def unregister_user( 

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

1078 ) -> None: 

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

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

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

1082 

1083 async def input( 

1084 self, 

1085 jid: JID, 

1086 text: str | None = None, 

1087 mtype: MessageTypes = "chat", 

1088 **input_kwargs: Any, # noqa:ANN401 

1089 ) -> str: 

1090 """ 

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

1092 

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

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

1095 

1096 :param jid: The JID we want input from 

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

1098 :param mtype: Message type 

1099 :return: The user's reply 

1100 """ 

1101 return await self.__chat_commands_handler.input( 

1102 jid, text, mtype=mtype, **input_kwargs 

1103 ) 

1104 

1105 async def send_qr( 

1106 self, 

1107 text: str, 

1108 **msg_kwargs: Any, # noqa:ANN401 

1109 ) -> str | None: 

1110 """ 

1111 Sends a QR Code to a JID 

1112 

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

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

1115 

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

1117 :param msg_kwargs: Optional additional arguments to pass to 

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

1119 code 

1120 """ 

1121 try: 

1122 import qrcode 

1123 except ImportError: 

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

1125 raise 

1126 qr = qrcode.make(text) 

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

1128 qr.save(f.name) 

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

1130 return url 

1131 

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

1133 # """ 

1134 # Called by the slidge entrypoint on normal exit. 

1135 # 

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

1137 # the gateway component itself. 

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

1139 # """ 

1140 log.debug("Shutting down") 

1141 tasks = [] 

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

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

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

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

1146 return tasks 

1147 

1148 

1149SLIXMPP_PLUGINS = [ 

1150 "xep_0030", # Service discovery 

1151 "xep_0045", # Multi-User Chat 

1152 "xep_0050", # Adhoc commands 

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

1154 "xep_0055", # Jabber search 

1155 "xep_0059", # Result Set Management 

1156 "xep_0066", # Out of Band Data 

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

1158 "xep_0077", # In-band registration 

1159 "xep_0084", # User Avatar 

1160 "xep_0085", # Chat state notifications 

1161 "xep_0100", # Gateway interaction 

1162 "xep_0106", # JID Escaping 

1163 "xep_0115", # Entity capabilities 

1164 "xep_0122", # Data Forms Validation 

1165 "xep_0128", # Service Discovery Extensions 

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

1167 "xep_0172", # User nickname 

1168 "xep_0184", # Message Delivery Receipts 

1169 "xep_0199", # XMPP Ping 

1170 "xep_0221", # Data Forms Media Element 

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

1172 "xep_0249", # Direct MUC Invitations 

1173 "xep_0264", # Jingle Content Thumbnails 

1174 "xep_0280", # Carbons 

1175 "xep_0292_provider", # VCard4 

1176 "xep_0308", # Last message correction 

1177 "xep_0313", # Message Archive Management 

1178 "xep_0317", # Hats 

1179 "xep_0319", # Last User Interaction in Presence 

1180 "xep_0333", # Chat markers 

1181 "xep_0334", # Message Processing Hints 

1182 "xep_0356", # Privileged Entity 

1183 "xep_0363", # HTTP file upload 

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

1185 "xep_0402", # PEP Native Bookmarks 

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

1187 "xep_0424", # Message retraction 

1188 "xep_0425", # Message moderation 

1189 "xep_0444", # Message reactions 

1190 "xep_0447", # Stateless File Sharing 

1191 "xep_0449", # Stickers 

1192 "xep_0461", # Message replies 

1193 "xep_0462", # Pubsub Type Filtering 

1194 "xep_0463", # MUC Affiliation Versioning 

1195 "xep_0469", # Bookmark Pinning 

1196 "xep_0490", # Message Displayed Synchronization 

1197 "xep_0492", # Chat Notification Settings 

1198 # "xep_0503", # Server-side spaces 

1199 "xep_0511", # Link Metadata 

1200] 

1201 

1202 

1203async def _loop(coro: Callable[[], Awaitable[None]], sleep: int) -> None: 

1204 while True: 

1205 await coro() 

1206 await asyncio.sleep(sleep) 

1207 

1208 

1209def _safe_rm_parent(path: Path) -> None: 

1210 try: 

1211 path.unlink() 

1212 path.parent.rmdir() 

1213 except OSError as exc: 

1214 log.warning("%s couldn't be cleanly removed: !r", exc) 

1215 

1216 

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

1218 

1219log = logging.getLogger(__name__)