#!/usr/bin/env python3 # SAT plugin for managing gateways (xep-0100) # Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import cast from pydantic import BaseModel, Field from twisted.internet import defer, reactor from twisted.words.protocols.jabber import jid from tx_xmpp import disco from libervia.backend.core import exceptions from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger from libervia.backend.models.core import DiscoIdentity from libervia.backend.models.types import StrictJIDType from libervia.backend.tools import xml_tools log = getLogger(__name__) PLUGIN_INFO = { C.PI_NAME: "Gateways Plugin", C.PI_IMPORT_NAME: "XEP-0100", C.PI_TYPE: "XEP", C.PI_PROTOCOLS: ["XEP-0100"], C.PI_DEPENDENCIES: ["XEP-0077"], C.PI_MAIN: "XEP_0100", C.PI_DESCRIPTION: _("""Implementation of Gateways protocol"""), } WARNING_MSG = D_( "Please exercise caution. Gateways facilitate the use of external instant messaging " "platforms (legacy IM), enabling you to view your contacts as XMPP contacts. " "However, this process routes all messages through the external legacy IM server, " "raising significant privacy concerns. Specifically, it is possible for the external " "server, often operated by a private company, to monitor, record, and analyze all " "messages that traverse the gateway." ) GATEWAY_TIMEOUT = 10 # time to wait before cancelling a gateway disco info, in seconds TYPE_DESCRIPTIONS = { "irc": D_("Internet Relay Chat"), "xmpp": D_("XMPP"), "qq": D_("Tencent QQ"), "simple": D_("SIP/SIMPLE"), "icq": D_("ICQ"), "yahoo": D_("Yahoo! Messenger"), "gadu-gadu": D_("Gadu-Gadu"), "aim": D_("AOL Instant Messenger"), "msn": D_("Windows Live Messenger"), "smtp": D_("Email"), } class GatewayData(BaseModel): entity: StrictJIDType identities: list[DiscoIdentity] class FoundGateways(BaseModel): available: list[GatewayData] unavailable: list[StrictJIDType] = Field( description="Gateways registered but not answering." ) class XEP_0100(object): def __init__(self, host): log.info(_("Gateways plugin initialization")) self.host = host self.__gateways = ( {} ) # dict used to construct the answer to gateways_find. Key = target jid host.bridge.add_method( "gateways_find", ".plugin", in_sign="ss", out_sign="s", method=self._gateways_find, ) host.bridge.add_method( "gateways_find_xmlui", ".plugin", in_sign="ss", out_sign="s", method=self._gateways_find_xmlui, ) host.bridge.add_method( "gateway_register", ".plugin", in_sign="ss", out_sign="s", method=self._gateway_register, async_=True, ) self.__menu_id = host.register_callback(self._gateways_menu, with_data=True) self.__selected_id = host.register_callback( self._gateway_selected_cb, with_data=True ) host.import_menu( (D_("Service"), D_("Gateways")), self._gateways_menu, security_limit=1, help_string=D_("Find gateways"), ) def _gateways_menu(self, data, profile): """XMLUI activated by menu: return Gateways UI @param profile: %(doc_profile)s """ client = self.host.get_client(profile) try: jid_ = jid.JID( data.get(xml_tools.form_escape("external_jid"), client.jid.host) ) except RuntimeError: raise exceptions.DataError(_("Invalid JID")) d = self.gateways_find_raw(jid_, profile) d.addCallback(self._gateways_result_2_xmlui, jid_) d.addCallback(lambda xmlui: {"xmlui": xmlui.toXml()}) return d def _gateways_result_2_xmlui(self, result, entity): xmlui = xml_tools.XMLUI(title=_("Gateways manager (%s)") % entity.full()) xmlui.addText(_(WARNING_MSG)) xmlui.addDivider("dash") adv_list = xmlui.change_container( "advanced_list", columns=3, selectable="single", callback_id=self.__selected_id, ) for success, gateway_data in result: if not success: fail_cond, disco_item = gateway_data xmlui.addJid(disco_item.entity) xmlui.addText(_("Failed (%s)") % fail_cond) xmlui.addEmpty() else: jid_, data = gateway_data for datum in data: identity, name = datum adv_list.set_row_index(jid_.full()) xmlui.addJid(jid_) xmlui.addText(name) xmlui.addText(self._get_identity_desc(identity)) adv_list.end() xmlui.addDivider("blank") xmlui.change_container("advanced_list", columns=3) xmlui.addLabel(_("Use external XMPP server")) xmlui.addString("external_jid") xmlui.addButton(self.__menu_id, _("Go !"), fields_back=("external_jid",)) return xmlui def _gateway_selected_cb(self, data, profile): try: target_jid = jid.JID(data["index"]) except (KeyError, RuntimeError): log.warning(_("No gateway index selected")) return {} d = self.gateway_register(target_jid, profile) d.addCallback(lambda xmlui: {"xmlui": xmlui.toXml()}) return d def _get_identity_desc(self, identity): """Return a human readable description of identity @param identity: tuple as returned by Disco identities (category, type) """ category, type_ = identity if category != "gateway": log.error( _( 'INTERNAL ERROR: identity category should always be "gateway" in ' '_getTypeString, got "%s"' ) % category ) try: return _(TYPE_DESCRIPTIONS[type_]) except KeyError: return _("Unknown IM") def _registration_successful(self, jid_, profile): """Called when in_band registration is ok, we must now follow the rest of procedure""" log.debug(_("Registration successful, doing the rest")) self.host.contact_add(jid_, profile_key=profile) self.host.presence_set(jid_, profile_key=profile) def _gateway_register(self, target_jid_s, profile_key=C.PROF_KEY_NONE): client = self.host.get_client(profile_key) d = self.gateway_register(client, jid.JID(target_jid_s)) d.addCallback(lambda xmlui: xmlui.toXml()) return d def gateway_register( self, client: SatXMPPEntity, target_jid: jid.JID ) -> defer.Deferred: """Register gateway using in-band registration, then log-in to gateway""" return defer.ensureDeferred( self.host.plugins["XEP-0077"].in_band_register( client, target_jid, self._registration_successful ) ) def _gateways_find(self, target_jid_s: str, profile_key: str) -> defer.Deferred[str]: client = self.host.get_client(profile_key) target_jid = jid.JID(target_jid_s) if target_jid_s else client.server_jid d = defer.ensureDeferred(self.gateways_find(client, target_jid)) d.addCallback(lambda found_gateways: found_gateways.model_dump_json()) # The Deferred will actually return a str due to `model_dump_json`, but type # checker doesn't get that. d = cast(defer.Deferred[str], d) return d async def gateways_find( self, client: SatXMPPEntity, target: jid.JID ) -> FoundGateways: """Find gateways and convert FoundGateways instance.""" gateways_data = await self.gateways_find_raw(client, target) available = [] unavailable = [] for gw_available, data in gateways_data: if gw_available: data = cast(tuple[jid.JID, list[tuple[tuple[str, str], str]]], data) identities = [] for (category, type_), name in data[1]: identities.append( DiscoIdentity(name=name, category=category, type=type_) ) available.append(GatewayData(entity=data[0], identities=identities)) else: disco_item = cast(disco.DiscoItem, data[1]) unavailable.append(disco_item.entity) return FoundGateways(available=available, unavailable=unavailable) def _gateways_find_xmlui( self, target_jid_s: str, profile_key: str ) -> defer.Deferred[str]: target_jid = jid.JID(target_jid_s) client = self.host.get_client(profile_key) d = defer.ensureDeferred(self.gateways_find_raw(client, target_jid)) d.addCallback(self._gateways_result_2_xmlui, target_jid) d.addCallback(lambda xmlui: xmlui.toXml()) d = cast(defer.Deferred[str], d) return d async def gateways_find_raw(self, client: SatXMPPEntity, target: jid.JID) -> list: """Find gateways in the target JID, using discovery protocol""" log.debug( _("find gateways (target = {target}, profile = {profile})").format( target=target.full(), profile=client.profile ) ) disco = await client.disco.requestItems(target) if len(disco._items) == 0: log.debug(_("No gateway found")) return [] defers_ = [] for item in disco._items: log.debug(_("item found: {}").format(item.entity)) defers_.append(client.disco.requestInfo(item.entity)) deferred_list = defer.DeferredList(defers_) dl_result = await deferred_list reactor.callLater(GATEWAY_TIMEOUT, deferred_list.cancel) items = disco._items ret = [] for idx, (success, result) in enumerate(dl_result): if not success: if isinstance(result.value, defer.CancelledError): msg = _("Timeout") else: try: msg = result.value.condition except AttributeError: msg = str(result) ret.append((success, (msg, items[idx]))) else: entity = items[idx].entity gateways = [ (identity, list(result.identities[identity].values())[0]) for identity in result.identities if identity[0] == "gateway" ] if gateways: log.debug( _("Found gateway [{jid}]: {identity_name}").format( jid=entity.full(), identity_name=" - ".join( [gateway[1].name for gateway in gateways] ), ) ) ret.append((success, (entity, gateways))) else: log.debug( _("Skipping [{jid}] which is not a gateway").format( jid=entity.full() ) ) return ret