libervia-backend/libervia/backend/plugins/plugin_xep_0433.py
Goffi 6a7b7bc36a core: Replace legacy INI config system with Pydantic-based TOML configuration.
This is a huge configuration refactoring.

The configuration system now uses Pydantic settings, with better validation, better
support for environment variables and other goodies.

The main config files now use TOML instead of INI, this allow for better handling of
complex types, notably dict.

Plugins, components and frontends can declare their config settings in a class which will
be imported dynamically.
2025-11-30 12:27:35 +01:00

347 lines
12 KiB
Python

#!/usr/bin/env python3
# Libervia plugin for Extended Channel Search (XEP-0433)
# Copyright (C) 2009-2025 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/>.
import difflib
from typing import Any, Final, Iterator, Self, cast
from pydantic import BaseModel, Field, ConfigDict, RootModel, model_validator
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from twisted.words.xish import domish
from wokkel import data_form
from libervia.backend import G
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 _
from libervia.backend.core.log import getLogger
from libervia.backend.models.types import JIDType
from libervia.backend.plugins import plugin_misc_jid_search
from libervia.backend.plugins.plugin_xep_0059 import RSMRequest
log = getLogger(__name__)
# Namespaces
NS_CHANNEL_SEARCH: Final[str] = "urn:xmpp:channel-search:0"
NS_SEARCH: Final[str] = f"{NS_CHANNEL_SEARCH}:search"
NS_SEARCH_PARAMS: Final[str] = f"{NS_CHANNEL_SEARCH}:search-params"
NS_ORDER: Final[str] = f"{NS_CHANNEL_SEARCH}:order"
NS_ERROR: Final[str] = f"{NS_CHANNEL_SEARCH}:error"
# Common sort keys
SORT_ADDRESS: Final[str] = f"{{{NS_ORDER}}}address"
SORT_NUSERS: Final[str] = f"{{{NS_ORDER}}}nusers"
PLUGIN_INFO = {
C.PI_NAME: "Extended Channel Search",
C.PI_IMPORT_NAME: "XEP-0433",
C.PI_TYPE: "XEP",
C.PI_MODES: C.PLUG_MODE_BOTH,
C.PI_DEPENDENCIES: ["XEP-0059", "JID_SEARCH"],
C.PI_RECOMMENDATIONS: [],
C.PI_MAIN: "XEP_0433",
C.PI_HANDLER: "no",
C.PI_DESCRIPTION: _("Cross-domain search for public group chats"),
}
class SearchRequest(BaseModel):
"""Parameters for channel search request."""
model_config = ConfigDict(extra="forbid")
query: str | None = Field(None, alias="q")
all: bool = False
sinname: bool = True
sindescription: bool = True
sinaddr: bool = True
min_users: int | None = Field(default=None, ge=0)
types: list[str] = []
key: str = SORT_ADDRESS
rsm: RSMRequest | None = None
@model_validator(mode="after")
def check_conflicts(self) -> Self:
if self.all and self.query:
raise ValueError('Cannot combine "all" with search query')
return self
@classmethod
def from_element(cls, element: domish.Element) -> Self:
"""Parse from XMPP data form element."""
form = data_form.Form.fromElement(element)
if form.formNamespace != NS_SEARCH_PARAMS:
raise ValueError("Invalid FORM_TYPE")
kwargs = {}
if "q" in form:
kwargs["query"] = form["q"]
if "all" in form:
kwargs["all"] = form["all"]
if "min_users" in form:
try:
kwargs["min_users"] = int(form["min_users"])
except ValueError:
raise ValueError("Invalid min_users value")
for field in ["sinname", "sindescription", "sinaddr", "types", "key"]:
if field in form:
kwargs[field] = form[field]
return cls(**kwargs)
def to_form(self) -> data_form.Form:
"""Convert to "submit" data form"""
form = data_form.Form("submit", formNamespace=NS_SEARCH_PARAMS)
# Add fields with original XML field names
if self.query is not None:
form.addField(data_form.Field(var="q", value=self.query))
if self.all:
form.addField(data_form.Field("boolean", "all", value=True))
if not self.sinname:
form.addField(data_form.Field("boolean", "sinname", value=False))
if not self.sindescription:
form.addField(data_form.Field("boolean", "sindescription", value=False))
if not self.sinaddr:
form.addField(data_form.Field("boolean", "sinaddr", value=False))
if self.min_users is not None:
form.addField(data_form.Field(var="min_users", value=str(self.min_users)))
if self.types:
form.addField(data_form.Field("list-multi", "types", values=self.types))
if self.key != SORT_ADDRESS:
form.addField(data_form.Field("list-single", "key", value=self.key))
return form
def to_element(self) -> domish.Element:
"""Convert to XMPP data form submission."""
form = self.to_form()
search_elt = domish.Element((NS_SEARCH, "search"))
search_elt.addChild(form.toElement())
if self.rsm is not None:
search_elt.addChild(self.rsm.to_element())
return search_elt
class SearchItem(BaseModel):
"""Represents a single channel search result."""
address: JIDType
name: str | None = None
description: str | None = None
language: str | None = None
nusers: int | None = Field(default=None, ge=0)
service_type: str | None = None
is_open: bool | None = None
anonymity_mode: str | None = None
@classmethod
def from_element(cls, element: domish.Element) -> Self:
"""Parse from <item> element."""
if not (element.name == "item" and element.uri == NS_SEARCH):
raise ValueError("Invalid channel item element")
address = element.getAttribute("address")
if not address:
raise ValueError("Missing required address attribute")
data: dict[str, Any] = {"address": jid.JID(address)}
for child in element.elements():
if child.uri != NS_SEARCH:
continue
content = str(child)
match (name := child.name.replace("-", "_")):
case "nusers":
data[name] = int(content)
case "is_open":
data[name] = content.lower() == "true"
case "service_type" | "anonymity_mode" if content:
data[name] = content
case _:
data[name] = content
return cls(**data)
def to_element(self) -> domish.Element:
"""Convert to <item> element."""
item = domish.Element((NS_SEARCH, "item"))
item["address"] = str(self.address)
field_mappings = {
"name": "name",
"description": "description",
"language": "language",
"nusers": "nusers",
"service_type": "service-type",
"anonymity_mode": "anonymity-mode",
}
for field, element_name in field_mappings.items():
value = getattr(self, field)
if value is not None:
elem = item.addElement((NS_SEARCH, element_name))
elem.addContent(
str(value).lower() if isinstance(value, bool) else str(value)
)
if self.is_open is not None:
item.addElement(
(NS_SEARCH, "is-open"), content="true" if self.is_open else "false"
)
return item
class SearchItems(RootModel):
root: list[SearchItem]
def __iter__(self) -> Iterator[SearchItem]: # type: ignore
return iter(self.root)
def __getitem__(self, item) -> str:
return self.root[item]
def __len__(self) -> int:
return len(self.root)
def append(self, item: SearchItem) -> None:
self.root.append(item)
def sort(self, key=None, reverse=False) -> None:
self.root.sort(key=key, reverse=reverse) # type: ignore
@classmethod
def from_element(cls, element: domish.Element) -> Self:
if element.name == "result" and element.uri == NS_SEARCH:
result_elt = element
else:
try:
result_elt = next(element.elements(NS_SEARCH, "result"))
except StopIteration:
raise exceptions.NotFound("No <result> element found.")
items = []
for item_elt in result_elt.elements(NS_SEARCH, "item"):
items.append(SearchItem.from_element(item_elt))
return cls(items)
def to_element(self) -> domish.Element:
result_elt = domish.Element((NS_SEARCH, "result"))
for search_item in self.root:
result_elt.addChild(search_item.to_element())
return result_elt
class XEP_0433:
"""Implementation of XEP-0433 Extended Channel Search."""
namespace: Final[str] = NS_CHANNEL_SEARCH
def __init__(self, host: Any):
log.info(f"Plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization.")
self.host = host
host.trigger.add(
"JID_SEARCH_perform_search", self.jid_search_perform_search_trigger
)
self.allow_external = G.config.common.allow_external_search
self.group_chat_search_default_jid = jid.JID(
G.config.common.group_chat_search_default_jid
)
host.bridge.add_method(
"extended_search_request",
".plugin",
in_sign="sss",
out_sign="s",
method=self._search,
async_=True,
)
async def jid_search_perform_search_trigger(
self,
client: SatXMPPEntity,
search_term: str,
options: plugin_misc_jid_search.Options,
sequence_matcher: difflib.SequenceMatcher,
matches: plugin_misc_jid_search.SearchItems,
) -> bool:
if options.groupchat and self.allow_external:
log.debug(f"Search {search_term!r} at {self.group_chat_search_default_jid}.")
try:
external_items = await self.search(
client,
self.group_chat_search_default_jid,
SearchRequest(q=search_term),
)
except Exception as e:
log.warning(f"Can't do external search: {e}.")
return True
for search_item in external_items:
room_search_item = plugin_misc_jid_search.RoomSearchItem(
entity=search_item.address,
name=(
search_item.name
or search_item.address.user
or search_item.address.full()
),
local=False,
service_type=search_item.service_type,
is_open=search_item.is_open,
anonymity_mode=search_item.anonymity_mode,
description=search_item.description,
language=search_item.language,
nusers=search_item.nusers,
)
matches.append(room_search_item)
return True
def _search(
self, target: str, search_request: str, profile: str
) -> defer.Deferred[str]:
client = self.host.get_client(profile)
d = defer.ensureDeferred(
self.search(
client, jid.JID(target), SearchRequest.model_validate_json(search_request)
)
)
d.addCallback(
lambda search_items: search_items.model_dump_json(exclude_none=True)
)
d = cast(defer.Deferred[str], d)
return d
async def search(
self, client: SatXMPPEntity, target: jid.JID, search_request: SearchRequest
) -> SearchItems:
"""Do a Search"""
iq_elt = client.IQ("get")
iq_elt.addChild(search_request.to_element())
iq_result_elt = await iq_elt.send(target.full())
return SearchItems.from_element(iq_result_elt)