libervia-backend/libervia/backend/plugins/plugin_xep_0100.py
Goffi fd58b78762 core, dependencies: update Tx-XMPP to v0.13.0:
`DiscoFeature.identities` format has evolved so `(category, map)` can be checked directly
again. The code using identities has been updated.
2026-04-21 12:49:30 +02:00

323 lines
12 KiB
Python

#!/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 <http://www.gnu.org/licenses/>.
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