libervia-backend/libervia/backend/plugins/plugin_misc_jid_search.py
2024-06-19 18:44:57 +02:00

264 lines
9 KiB
Python

#!/usr/bin/env python3
# Libervia plugin to handle XMPP entities search
# Copyright (C) 2009-2023 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 collections import OrderedDict
from dataclasses import dataclass, asdict
import difflib
from typing import List, Optional
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
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.tools.common import data_format
log = getLogger(__name__)
PLUGIN_INFO = {
C.PI_NAME: "JID Search",
C.PI_IMPORT_NAME: "JID_SEARCH",
C.PI_TYPE: C.PLUG_TYPE_MISC,
C.PI_MODES: C.PLUG_MODE_BOTH,
C.PI_PROTOCOLS: [],
C.PI_DEPENDENCIES: [],
C.PI_RECOMMENDATIONS: [],
C.PI_MAIN: "JidSearch",
C.PI_HANDLER: "no",
C.PI_DESCRIPTION: _("""Search for XMPP entities"""),
}
RATIO_CUTOFF = 0.6
MAX_CACHE_SIZE = 10
@dataclass
class JidSearchItem:
entity: jid.JID
name: str = ""
in_roster: bool = False
groups: list[str] | None = None
exact_match: bool = False
relevance: float | None = None
JidSearchCache = OrderedDict[str, list[JidSearchItem]]
class JidSearch:
def __init__(self, host) -> None:
log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
self.host = host
host.bridge.add_method(
"jid_search",
".plugin",
in_sign="sss",
out_sign="s",
method=self._search,
async_=True,
)
def profile_connecting(self, client: SatXMPPEntity) -> None:
client._jid_search_cache = JidSearchCache()
def _search(self, search_term: str, options_s: str, profile: str) -> defer.Deferred:
client = self.host.get_client(profile)
d = defer.ensureDeferred(
self.search(client, search_term, data_format.deserialise(options_s))
)
d.addCallback(
lambda search_items: data_format.serialise([asdict(i) for i in search_items])
)
return d
async def search(
self, client: SatXMPPEntity, search_term: str, options: Optional[dict] = None
) -> List[JidSearchItem]:
"""Searches for entities in various locations.
@param client: The SatXMPPEntity client where the search is to be performed.
@param search_term: The query to be searched.
@param options: Additional search options.
@return: A list of matches found.
"""
search_term = search_term.strip().lower()
sequence_matcher = difflib.SequenceMatcher()
sequence_matcher.set_seq1(search_term)
# FIXME: cache can give different results due to the filtering mechanism (if a
# cached search term match the beginning of current search term, its results a
# re-used and filtered, and sometimes items can be missing in compraison to the
# results without caching). This may need to be fixed.
cache: JidSearchCache = client._jid_search_cache
# Look for a match in the cache
for cache_key in cache:
if search_term.startswith(cache_key):
log.debug(
f"Match found in cache for {search_term!r} in [{client.profile}]."
)
# If an exact match is found, return the results as is
if search_term == cache_key:
log.debug("Exact match found in cache, reusing results.")
matches = cache[cache_key]
else:
# If only the beginning matches, filter the cache results
log.debug("Prefix match found in cache, filtering results.")
matches = []
for jid_search_item in cache[cache_key]:
self._process_matching(
search_term, sequence_matcher, matches, jid_search_item
)
cache.move_to_end(cache_key)
break
else:
# If no match is found in the cache, perform a new search
matches = await self._perform_search(client, search_term, sequence_matcher)
cache[search_term] = matches
if len(cache) > MAX_CACHE_SIZE:
cache.popitem(last=False)
# If no exact match is found, but the search term is a valid JID, we add the JID
# as a result
exact_match = any(m.exact_match for m in matches)
if not exact_match and "@" in search_term:
try:
search_jid = jid.JID(search_term)
except jid.InvalidFormat:
pass
else:
matches.append(
JidSearchItem(
entity=search_jid,
in_roster=False,
exact_match=True,
relevance=1,
)
)
matches.sort(
key=lambda item: (item.exact_match, item.relevance or 0, item.in_roster),
reverse=True,
)
return matches
def _process_matching(
self,
search_term: str,
sequence_matcher: difflib.SequenceMatcher,
matches: List[JidSearchItem],
item: JidSearchItem,
) -> None:
"""Process matching of items
@param sequence_matcher: The sequence matcher to be used for the matching process.
@param matches: A list where the match is to be appended.
@param item: The item that to be matched.
@return: True if it was an exact match
"""
item_name_lower = item.name.lower()
item_entity_lower = item.entity.full().lower()
if search_term in (item_name_lower, item_entity_lower):
item.exact_match = True
item.relevance = 1
matches.append(item)
return
item.exact_match = False
sequence_matcher.set_seq2(item_name_lower)
name_ratio = sequence_matcher.ratio()
if name_ratio >= RATIO_CUTOFF:
item.relevance = name_ratio
matches.append(item)
return
sequence_matcher.set_seq2(item_entity_lower)
jid_ratio = sequence_matcher.ratio()
if jid_ratio >= RATIO_CUTOFF:
item.relevance = jid_ratio
matches.append(item)
return
localpart = item.entity.user.lower() if item.entity.user else ""
if localpart:
sequence_matcher.set_seq2(localpart)
domain_ratio = sequence_matcher.ratio()
if domain_ratio >= RATIO_CUTOFF:
item.relevance = domain_ratio
matches.append(item)
return
if item.groups:
group_ratios = []
for group in item.groups:
sequence_matcher.set_seq2(group.lower())
group_ratios.append(sequence_matcher.ratio())
group_ratio = max(group_ratios)
if group_ratio >= RATIO_CUTOFF:
item.relevance = group_ratio
matches.append(item)
return
domain = item.entity.host.lower()
sequence_matcher.set_seq2(domain)
domain_ratio = sequence_matcher.ratio()
if domain_ratio >= RATIO_CUTOFF:
item.relevance = domain_ratio
matches.append(item)
return
async def _perform_search(
self,
client: SatXMPPEntity,
search_term: str,
sequence_matcher: difflib.SequenceMatcher,
) -> List[JidSearchItem]:
"""Performs a new search when no match is found in the cache.
@param search_term: The query to be searched.
@param sequence_matcher: The SequenceMatcher object to be used for matching.
@return: A list of matches found.
"""
matches = []
try:
roster = client.roster
except AttributeError:
# components have no roster
roster = []
else:
roster = client.roster.get_items()
for roster_item in roster:
jid_search_item = JidSearchItem(
entity=roster_item.entity,
name=roster_item.name,
in_roster=True,
groups=list(roster_item.groups),
)
self._process_matching(
search_term, sequence_matcher, matches, jid_search_item
)
return matches