After 11 years of "temporary" monkey patches, I've finally made a friendly fork of Wokkel at https://www.goffi.org/forge/goffi/tx-xmpp with all needed PRs merged and changes from `sat_tmp`, using the same license as Wokkel (with agreement from contributors). No more monkey patching, and it will also stop the deprecation warning due to outdated packages used in Wokkel. Last but not least, it should unblock the move to the latest Twisted and Python versions.
685 lines
24 KiB
Python
685 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# Libervia plugin for Pubsub Contacts.
|
|
# 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 dataclasses import dataclass, field
|
|
from typing import Any, Iterable, Iterator, TYPE_CHECKING, Self, cast
|
|
|
|
from pydantic import BaseModel, Field, RootModel, field_validator
|
|
import shortuuid
|
|
from twisted.internet import defer
|
|
from twisted.words.protocols.jabber.jid import JID
|
|
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
|
|
from twisted.words.xish import domish
|
|
from wokkel import disco
|
|
from wokkel.pubsub import NS_PUBSUB, NS_PUBSUB_EVENT
|
|
from zope.interface import implementer
|
|
|
|
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.plugins.plugin_xep_0059 import XEP_0059, RSMRequest, RSMResponse
|
|
from libervia.backend.plugins.plugin_xep_0060 import XEP_0060
|
|
from libervia.backend.plugins.plugin_xep_0163 import XEP_0163
|
|
from libervia.backend.tools.xml_tools import find_ancestor
|
|
|
|
if TYPE_CHECKING:
|
|
from libervia.backend.core.main import LiberviaBackend
|
|
from wokkel.pubsub import ItemsEvent
|
|
|
|
log = getLogger(__name__)
|
|
|
|
|
|
PLUGIN_INFO = {
|
|
C.PI_NAME: "Pubsub Contacts",
|
|
C.PI_IMPORT_NAME: "CONTACTS",
|
|
C.PI_TYPE: "XEP",
|
|
C.PI_MODES: C.PLUG_MODE_BOTH,
|
|
C.PI_PROTOCOLS: [],
|
|
C.PI_DEPENDENCIES: ["XEP-0059", "XEP-0060", "XEP-0163"],
|
|
C.PI_RECOMMENDATIONS: [],
|
|
C.PI_MAIN: "PubsubContacts",
|
|
C.PI_HANDLER: "yes",
|
|
C.PI_DESCRIPTION: _("""Handle Contacts Metadata on Pubsub."""),
|
|
}
|
|
|
|
NS_CONTACTS_BASE = "urn:xmpp:contacts"
|
|
NS_CONTACTS = f"{NS_CONTACTS_BASE}:0"
|
|
NS_CONTACTS_GROUPS = "urn:xmpp:contacts-groups"
|
|
|
|
|
|
class Group(BaseModel):
|
|
|
|
id: str | None = None
|
|
name: str | None = None
|
|
description: str | None = None
|
|
encrypted: bool = True
|
|
|
|
@classmethod
|
|
def from_element(cls, element: domish.Element, metadata: dict | None = None) -> Self:
|
|
"""Parse from <group> element.
|
|
|
|
@param element: <group> element or a direct parent.
|
|
"""
|
|
if element.uri != NS_CONTACTS or element.name != "group":
|
|
try:
|
|
element = next(element.elements(NS_CONTACTS, "group"))
|
|
except StopIteration:
|
|
raise ValueError(f"Invalid element: {element.toXml()}")
|
|
|
|
group_id = element.getAttribute("id")
|
|
if not group_id:
|
|
raise ValueError(f"Invalid element, missing id: {element.toXml()}")
|
|
|
|
encrypted = False
|
|
if metadata is not None:
|
|
encrypted = metadata.get("encrypted", {})
|
|
if group_id in encrypted:
|
|
encrypted = True
|
|
|
|
name = element.getAttribute("name")
|
|
|
|
for child in element.elements(NS_CONTACTS, "description"):
|
|
description = str(child)
|
|
break
|
|
else:
|
|
description = None
|
|
|
|
return cls(id=group_id, name=name, description=description, encrypted=encrypted)
|
|
|
|
def to_element(self, id_only: bool = False) -> domish.Element:
|
|
"""Convert to <group> element.
|
|
|
|
@param id_only: if True, only use the "id" attribute (used for <contact/> element)
|
|
"""
|
|
group_elt = domish.Element((NS_CONTACTS, "group"))
|
|
if not self.id:
|
|
raise ValueError("self.id must be set.")
|
|
group_elt["id"] = self.id
|
|
if not id_only:
|
|
if not self.name:
|
|
raise ValueError("self.name must be set.")
|
|
group_elt["name"] = self.name
|
|
|
|
if self.description is not None:
|
|
group_elt.addElement("description", content=self.description)
|
|
|
|
return group_elt
|
|
|
|
|
|
class Groups(RootModel):
|
|
root: list[Group]
|
|
|
|
def __iter__(self) -> Iterator[Group]: # type: ignore
|
|
return iter(self.root)
|
|
|
|
def __getitem__(self, item) -> Group:
|
|
return self.root[item]
|
|
|
|
def __len__(self) -> int:
|
|
return len(self.root)
|
|
|
|
def append(self, item: Group) -> None:
|
|
self.root.append(item)
|
|
|
|
def extend(self, items: Iterable[Group]) -> None:
|
|
self.root.extend(items)
|
|
|
|
def remove(self, item: Group) -> None:
|
|
self.root.remove(item)
|
|
|
|
def clear(self) -> None:
|
|
self.root.clear()
|
|
|
|
def sort(self, key=None, reverse=False) -> None:
|
|
self.root.sort(key=key, reverse=reverse) # type: ignore
|
|
|
|
|
|
class Contact(BaseModel):
|
|
|
|
id: str | None = None
|
|
name: str | None = None
|
|
identities: dict[str, str]
|
|
groups: Groups = Field(default_factory=lambda: Groups([]))
|
|
encrypted: bool = True
|
|
|
|
@field_validator("identities", mode="after")
|
|
@classmethod
|
|
def identities_is_not_empty(cls, value: dict[str, str]) -> dict[str, str]:
|
|
if not value:
|
|
raise ValueError('"identities" field must have at least one value.')
|
|
if len(value) > 1 or (next(iter(value)) != "jid"):
|
|
raise NotImplementedError(f'Only "jid" type is supported for now.')
|
|
return value
|
|
|
|
@classmethod
|
|
def from_element(
|
|
cls,
|
|
element: domish.Element,
|
|
contacts_data: "ContactsData",
|
|
metadata: dict | None = None,
|
|
) -> "Contact":
|
|
"""Parse from <contact> element.
|
|
|
|
@param element: <contact> element or a direct parent.
|
|
@param contacts_data: used to fill group data, as in <contact> we have only the
|
|
group id.
|
|
@param metadata: metadata as returned by "XEP_0060.get_items".
|
|
"""
|
|
if element.uri != NS_CONTACTS or element.name != "contact":
|
|
try:
|
|
element = next(element.elements(NS_CONTACTS, "contact"))
|
|
except StopIteration:
|
|
raise ValueError(f"Invalid element: {element.toXml()}")
|
|
|
|
data: dict[str, Any] = {"encrypted": False}
|
|
|
|
name = element.getAttribute("name")
|
|
if name is not None:
|
|
data["name"] = name
|
|
|
|
identities = {}
|
|
|
|
for child in element.elements():
|
|
if child.uri != NS_CONTACTS:
|
|
continue
|
|
|
|
match child.name:
|
|
case "identity":
|
|
identity_type = child.getAttribute("type")
|
|
if not identity_type:
|
|
raise ValueError(
|
|
"<identity> element must have a 'type' attribute"
|
|
)
|
|
identities[identity_type] = str(child)
|
|
|
|
case "group":
|
|
group = Group.from_element(child)
|
|
if not group.id:
|
|
log.warning(f'Invalid group, no "id", ignoring: {child.toXml()}')
|
|
continue
|
|
if "groups" not in data:
|
|
data["groups"] = Groups(root=[])
|
|
try:
|
|
existing_group = contacts_data.ids_to_groups[group.id]
|
|
except KeyError:
|
|
log.warning(f"Unknown group, ignoring: {group}")
|
|
continue
|
|
else:
|
|
group = existing_group
|
|
data["groups"].append(group)
|
|
|
|
if not identities:
|
|
raise exceptions.DataError(
|
|
"<contact> element must have at least one <identity> element."
|
|
)
|
|
|
|
data["identities"] = identities
|
|
try:
|
|
item_elt = find_ancestor(element, "item", [NS_PUBSUB, NS_PUBSUB_EVENT])
|
|
except exceptions.NotFound:
|
|
log.warning("Could not find <item> ancestor of <contact>")
|
|
else:
|
|
item_id = item_elt["id"]
|
|
data["id"] = item_id
|
|
if metadata is not None:
|
|
encrypted = metadata.get("encrypted", {})
|
|
if item_id in encrypted:
|
|
data["encrypted"] = True
|
|
|
|
return cls(**data)
|
|
|
|
def to_element(self) -> domish.Element:
|
|
"""Convert to <contact> element."""
|
|
contact_elt = domish.Element((NS_CONTACTS, "contact"))
|
|
|
|
if self.name is not None:
|
|
contact_elt["name"] = self.name
|
|
|
|
for identity_type, identity_value in self.identities.items():
|
|
identity_elt = contact_elt.addElement("identity", content=identity_value)
|
|
identity_elt["type"] = identity_type
|
|
|
|
for group in self.groups:
|
|
contact_elt.addChild(group.to_element(id_only=True))
|
|
|
|
return contact_elt
|
|
|
|
|
|
class Contacts(RootModel):
|
|
root: list[Contact]
|
|
|
|
def __iter__(self) -> Iterator[Contact]: # type: ignore
|
|
return iter(self.root)
|
|
|
|
def __getitem__(self, item) -> Contact:
|
|
return self.root[item]
|
|
|
|
def __len__(self) -> int:
|
|
return len(self.root)
|
|
|
|
def append(self, item: Contact) -> None:
|
|
self.root.append(item)
|
|
|
|
def extend(self, items: Iterable[Contact]) -> None:
|
|
self.root.extend(items)
|
|
|
|
def remove(self, item: Contact) -> None:
|
|
self.root.remove(item)
|
|
|
|
def clear(self) -> None:
|
|
self.root.clear()
|
|
|
|
def sort(self, key=None, reverse=False) -> None:
|
|
self.root.sort(key=key, reverse=reverse) # type: ignore
|
|
|
|
|
|
class ContactsRequest(BaseModel):
|
|
clear: bool = Field(
|
|
default=True, description="If true, retrieve clear text contacts."
|
|
)
|
|
encrypted: bool = Field(
|
|
default=True, description="If true, retrieve end-to-end encrypted text contacts."
|
|
)
|
|
ignore_cache: bool = Field(
|
|
default=False,
|
|
description=(
|
|
"If True, won't use cached value and will fetch from pubsub service again."
|
|
),
|
|
)
|
|
|
|
|
|
class ContactSetRequest(BaseModel):
|
|
contact: Contact
|
|
|
|
|
|
@dataclass
|
|
class ContactsData:
|
|
contacts: Contacts = field(default_factory=lambda: Contacts([]))
|
|
groups: Groups = field(default_factory=lambda: Groups([]))
|
|
_jid_to_contacts: dict[str, Contact] | None = None
|
|
_ids_to_groups: dict[str, Group] | None = None
|
|
|
|
@property
|
|
def jid_to_contacts(self) -> dict[str, Contact]:
|
|
"""A map from contact JID to Contact instance.
|
|
|
|
A str is used instead of jid.JID for convenience, as for now Contact.identities
|
|
only support str to str mapping.
|
|
"""
|
|
if self._jid_to_contacts is None:
|
|
if self.contacts is None:
|
|
return {}
|
|
jid_to_contacts = {}
|
|
for contact in self.contacts:
|
|
if not contact.id:
|
|
log.warning(f"ignoring contact with no id: {contact}")
|
|
continue
|
|
jid = contact.identities.get("jid")
|
|
if not jid:
|
|
log.warning(f"No JID found for {contact}, ignoring")
|
|
continue
|
|
jid_to_contacts[jid] = contact
|
|
self._jid_to_contacts = jid_to_contacts
|
|
return self._jid_to_contacts
|
|
|
|
@property
|
|
def ids_to_groups(self) -> dict[str, Group]:
|
|
"""A map from group IDs to Group instance."""
|
|
if self._ids_to_groups is None:
|
|
if self.groups is None:
|
|
return {}
|
|
ids_to_groups = {}
|
|
for group in self.groups:
|
|
if group.id is None:
|
|
log.warning(f'"id" not set for this group, ignoring: {group}')
|
|
continue
|
|
ids_to_groups[group.id] = group
|
|
self._ids_to_groups = ids_to_groups
|
|
return self._ids_to_groups
|
|
|
|
def reset(self) -> None:
|
|
"""Reset data internal values.
|
|
|
|
To be used when groups and contacts are fetched again.
|
|
"""
|
|
self._jid_to_contacts = self._ids_to_groups = None
|
|
|
|
|
|
class PubsubContacts:
|
|
namespace = NS_CONTACTS
|
|
|
|
def __init__(self, host: "LiberviaBackend") -> None:
|
|
log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
|
|
self.host = host
|
|
self._p = cast(XEP_0060, host.plugins["XEP-0060"])
|
|
self._rsm = cast(XEP_0059, host.plugins["XEP-0059"])
|
|
pep = cast(XEP_0163, host.plugins["XEP-0163"])
|
|
pep.add_pep_event(None, NS_CONTACTS_BASE, self._on_contacts_update)
|
|
pep.add_pep_event(None, NS_CONTACTS_GROUPS, self._on_groups_update)
|
|
host.bridge.add_method(
|
|
"ps_contacts_get",
|
|
".plugin",
|
|
in_sign="ss",
|
|
out_sign="s",
|
|
method=self._contacts_get,
|
|
async_=True,
|
|
)
|
|
host.bridge.add_method(
|
|
"ps_contact_set",
|
|
".plugin",
|
|
in_sign="ss",
|
|
out_sign="s",
|
|
method=self._contact_set,
|
|
async_=True,
|
|
)
|
|
|
|
def get_handler(self, client: SatXMPPEntity) -> "PubsubContactsHandler":
|
|
return PubsubContactsHandler()
|
|
|
|
def _on_contacts_update(self, itemsEvent, profile: str) -> None:
|
|
client = self.host.get_client(profile)
|
|
data = client.get_plugin_data(C.PI_IMPORT_NAME, ContactsData)
|
|
for item_elt in itemsEvent.items:
|
|
if item_elt.name == "retract":
|
|
item_id = item_elt["id"]
|
|
try:
|
|
to_delete = next(c for c in data.contacts if c.id == item_id)
|
|
except StopIteration:
|
|
pass
|
|
else:
|
|
data.contacts.remove(to_delete)
|
|
data.reset()
|
|
else:
|
|
item_id = item_elt["id"]
|
|
if item_elt.hasAttribute("encrypted"):
|
|
metadata = {"encrypted": {item_id: item_elt.encrypted}}
|
|
else:
|
|
metadata = None
|
|
|
|
contact = Contact.from_element(item_elt, data, metadata)
|
|
try:
|
|
existing_contact = next(c for c in data.contacts if c.id == item_id)
|
|
except StopIteration:
|
|
data.contacts.append(contact)
|
|
data.reset()
|
|
else:
|
|
# We only replace the contact if they differs
|
|
if contact != existing_contact:
|
|
data.contacts.remove(existing_contact)
|
|
data.contacts.append(contact)
|
|
data.reset()
|
|
|
|
def _on_groups_update(self, itemsEvent, profile: str) -> None:
|
|
client = self.host.get_client(profile)
|
|
data = client.get_plugin_data(C.PI_IMPORT_NAME, ContactsData)
|
|
for item_elt in itemsEvent.items:
|
|
if item_elt.name == "retract":
|
|
item_id = item_elt["id"]
|
|
try:
|
|
to_delete = next(g for g in data.groups if g.id == item_id)
|
|
except StopIteration:
|
|
pass
|
|
else:
|
|
data.groups.remove(to_delete)
|
|
data.reset()
|
|
else:
|
|
if item_elt.hasAttribute("encrypted"):
|
|
metadata = {"encrypted": {item_elt["id"]: item_elt.encrypted}}
|
|
else:
|
|
metadata = None
|
|
|
|
group = Group.from_element(item_elt, metadata)
|
|
assert group.id is not None
|
|
try:
|
|
existing_group = data.ids_to_groups[group.id]
|
|
except KeyError:
|
|
data.groups.append(group)
|
|
data.reset()
|
|
else:
|
|
# We only replace the group if they differs
|
|
if group != existing_group:
|
|
data.groups.remove(existing_group)
|
|
data.groups.append(group)
|
|
data.reset()
|
|
|
|
def _contacts_get(
|
|
self, contacts_request_s: str, profile_key: str
|
|
) -> defer.Deferred[str]:
|
|
client = self.host.get_client(profile_key)
|
|
contacts_request = ContactsRequest.model_validate_json(contacts_request_s)
|
|
d = defer.ensureDeferred(self.contacts_get(client, contacts_request))
|
|
d.addCallback(lambda contacts: contacts.model_dump_json())
|
|
d = cast(defer.Deferred[str], d)
|
|
return d
|
|
|
|
async def contacts_get(
|
|
self, client: SatXMPPEntity, contacts_request: ContactsRequest
|
|
) -> Contacts:
|
|
"""Retrieve contacts from pubsub.
|
|
|
|
This method fetches contacts and groups from the pubsub service.
|
|
If contacts_request.ignore_cache is not set, cached version will be used if it
|
|
exists.
|
|
@param client: client instance
|
|
@param contacts_request: Request parameters
|
|
@return: Retrieved contacts
|
|
"""
|
|
data = client.get_plugin_data(C.PI_IMPORT_NAME, ContactsData)
|
|
if contacts_request.ignore_cache:
|
|
data.contacts.clear()
|
|
data.groups.clear()
|
|
data.reset()
|
|
else:
|
|
if data.contacts:
|
|
return data.contacts
|
|
# We first retrieve groups
|
|
rsm_request = RSMRequest(max=20)
|
|
while True:
|
|
try:
|
|
groups_items, groups_metadata = await self._p.get_items(
|
|
client, None, NS_CONTACTS_GROUPS, rsm_request=rsm_request
|
|
)
|
|
except exceptions.NotFound:
|
|
log.debug("Group node doesn't exist.")
|
|
break
|
|
except Exception as e:
|
|
log.warning(f"Can't retrieve groups: {e}")
|
|
break
|
|
else:
|
|
data.groups.extend(
|
|
Group.from_element(e, groups_metadata) for e in groups_items
|
|
)
|
|
if rsm_dict := groups_metadata.get("rsm"):
|
|
rsm_response = RSMResponse(**rsm_dict)
|
|
rsm_request = self._rsm.get_next_request(
|
|
rsm_request, rsm_response, False
|
|
)
|
|
if rsm_request is None:
|
|
break
|
|
else:
|
|
break
|
|
|
|
# Then contacts
|
|
rsm_request = RSMRequest(max=1)
|
|
while True:
|
|
try:
|
|
contacts_items, metadata = await self._p.get_items(
|
|
client, None, NS_CONTACTS_BASE, rsm_request=rsm_request
|
|
)
|
|
except exceptions.NotFound:
|
|
log.debug("Contacts node doesn't exist.")
|
|
break
|
|
except Exception as e:
|
|
log.warning(f"Can't retrieve contacts: {e}")
|
|
break
|
|
else:
|
|
try:
|
|
new_contacts = Contacts(
|
|
[Contact.from_element(e, data, metadata) for e in contacts_items]
|
|
)
|
|
except Exception as e:
|
|
log.warning(f"Can't parse contacts: {e}")
|
|
raise e
|
|
data.contacts.extend(new_contacts)
|
|
if rsm_dict := metadata.get("rsm"):
|
|
rsm_response = RSMResponse(**rsm_dict)
|
|
rsm_request = self._rsm.get_next_request(
|
|
rsm_request, rsm_response, False
|
|
)
|
|
if rsm_request is None:
|
|
break
|
|
else:
|
|
break
|
|
return data.contacts
|
|
|
|
def _contact_set(
|
|
self, contacts_set_request_s: str, profile_key: str
|
|
) -> defer.Deferred[str]:
|
|
client = self.host.get_client(profile_key)
|
|
contacts_set_request = ContactSetRequest.model_validate_json(
|
|
contacts_set_request_s
|
|
)
|
|
d = defer.ensureDeferred(self.contacts_set(client, contacts_set_request))
|
|
return d
|
|
|
|
async def contacts_set(
|
|
self, client: SatXMPPEntity, contacts_set_request: ContactSetRequest
|
|
) -> str:
|
|
"""Add or update contact.
|
|
|
|
This method will add the contact to the contacts node, creating it if necessary.
|
|
It will first check if the contact already exist, and update the relevant item in
|
|
this case.
|
|
Similarly, contact groups will be created or used if they already exist, and the
|
|
groups node will be created if necessary.
|
|
|
|
@param client: client instance
|
|
@param contacts_set_request: Set request data.
|
|
@return: contact item ID
|
|
"""
|
|
# we need to fetch contacts to match existing ones (or groups).
|
|
await self.contacts_get(client, ContactsRequest())
|
|
data = client.get_plugin_data(C.PI_IMPORT_NAME, ContactsData)
|
|
contact = contacts_set_request.contact
|
|
|
|
if contact.groups:
|
|
new_groups = Groups([])
|
|
for group in contact.groups:
|
|
if group.id:
|
|
try:
|
|
existing_group = data.ids_to_groups[group.id]
|
|
except KeyError:
|
|
log.warning(f"Can't find group with id {group.id}, ignoring.")
|
|
continue
|
|
elif group.name:
|
|
group.name = group.name.strip()
|
|
if not group.name:
|
|
log.warning("A group name can't only contain spaces, ignoring.")
|
|
continue
|
|
try:
|
|
existing_group = next(
|
|
g
|
|
for g in data.groups
|
|
if g.name and g.name.lower() == group.name.lower()
|
|
)
|
|
except StopIteration:
|
|
log.debug(f"Adding new group: {group.name!r}")
|
|
new_id = shortuuid.uuid()
|
|
group.id = new_id
|
|
extra = {
|
|
self._p.EXTRA_AUTOCREATE: True,
|
|
self._p.EXTRA_PUBLISH_OPTIONS: {
|
|
XEP_0060.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST
|
|
},
|
|
}
|
|
if contact.encrypted:
|
|
extra["encrypted"] = True
|
|
item_id = await self._p.send_item(
|
|
client,
|
|
None,
|
|
NS_CONTACTS_GROUPS,
|
|
group.to_element(),
|
|
item_id=new_id,
|
|
extra=extra,
|
|
)
|
|
existing_group = group
|
|
else:
|
|
log.warning(f"Group has no name nor id, ignoring: {group!r}.")
|
|
continue
|
|
new_groups.append(existing_group)
|
|
contact.groups = new_groups
|
|
|
|
if contact.id is None:
|
|
try:
|
|
contact_jid = contact.identities["jid"]
|
|
except KeyError:
|
|
raise NotImplementedError("Contact has no JID, this is not supported yet")
|
|
existing_contact = data.jid_to_contacts.get(contact_jid)
|
|
if existing_contact is not None:
|
|
contact.id = existing_contact.id
|
|
contact_elt = contact.to_element()
|
|
extra = {
|
|
self._p.EXTRA_AUTOCREATE: True,
|
|
self._p.EXTRA_PUBLISH_OPTIONS: {
|
|
XEP_0060.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST
|
|
},
|
|
}
|
|
if contact.encrypted:
|
|
extra["encrypted"] = True
|
|
item_id = await self._p.send_item(
|
|
client,
|
|
None,
|
|
NS_CONTACTS_BASE,
|
|
contact_elt,
|
|
item_id=contact.id,
|
|
extra=extra,
|
|
)
|
|
assert item_id is not None
|
|
return item_id
|
|
|
|
|
|
@implementer(disco.IDisco)
|
|
class PubsubContactsHandler(XMPPHandler):
|
|
|
|
def getDiscoInfo(
|
|
self, requestor: JID, target: JID, nodeIdentifier: str = ""
|
|
) -> list[disco.DiscoFeature]:
|
|
"""Get disco info for pubsub extended discovery
|
|
|
|
@param requestor: JID of the requesting entity
|
|
@param target: JID of the target entity
|
|
@param nodeIdentifier: optional node identifier
|
|
@return: list of disco features
|
|
"""
|
|
return [
|
|
disco.DiscoFeature(NS_CONTACTS),
|
|
]
|
|
|
|
def getDiscoItems(
|
|
self, requestor: JID, target: JID, nodeIdentifier: str = ""
|
|
) -> list[disco.DiscoItem]:
|
|
"""Get disco items with extended discovery support
|
|
|
|
@param requestor: JID of the requesting entity
|
|
@param target: JID of the target entity
|
|
@param nodeIdentifier: optional node identifier
|
|
@return: list of disco items
|
|
"""
|
|
return []
|