plugin invitation: pubsub invitations:

- new Pubsub invitation plugin, to have a generic way to manage invitation on Pubsub based
  features
- `invitePreflight` and `onInvitationPreflight` method can be implemented to customise
  invitation for a namespace
- refactored events invitations to use the new plugin
- a Pubsub invitation can now be for a whole node instead of a specific item
- if invitation is for a node, a namespace can be specified to indicate what this node is
  about. It is then added in `extra` data
- an element (domish.Element) can be added in `extra` data, it will then be added in the
  invitation
- some code modernisation
This commit is contained in:
Goffi 2021-02-19 15:50:22 +01:00
commit 795931373a
4 changed files with 353 additions and 138 deletions

View file

@ -17,11 +17,13 @@
# 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 Optional
import shortuuid
from sat.core.i18n import _
from sat.core import exceptions
from sat.core.constants import Const as C
from sat.core.log import getLogger
from sat.core.xmpp import SatXMPPEntity
from sat.tools import utils
from sat.tools.common import uri as xmpp_uri
from sat.tools.common import date_utils
@ -41,7 +43,7 @@ PLUGIN_INFO = {
C.PI_IMPORT_NAME: "EVENTS",
C.PI_TYPE: "EXP",
C.PI_PROTOCOLS: [],
C.PI_DEPENDENCIES: ["XEP-0060", "INVITATION", "LIST_INTEREST"],
C.PI_DEPENDENCIES: ["XEP-0060", "INVITATION", "PUBSUB_INVITATION", "LIST_INTEREST"],
C.PI_RECOMMENDATIONS: ["XEP-0277", "EMAIL_INVITATION"],
C.PI_MAIN: "Events",
C.PI_HANDLER: "yes",
@ -61,8 +63,7 @@ class Events(object):
self._i = self.host.plugins.get("EMAIL_INVITATION")
self._b = self.host.plugins.get("XEP-0277")
self.host.registerNamespace("event", NS_EVENT)
self.host.plugins["INVITATION"].registerNamespace(NS_EVENT,
self.register)
self.host.plugins["PUBSUB_INVITATION"].register(NS_EVENT, self)
host.bridge.addMethod(
"eventGet",
".plugin",
@ -232,32 +233,6 @@ class Events(object):
raise exceptions.NotFound(_("No event with this id has been found"))
defer.returnValue(event_elt)
def register(self, client, name, extra, service, node, event_id, item_elt,
creator=False):
"""Register evenement in personal events list
@param service(jid.JID): pubsub service of the event
@param node(unicode): event node
@param event_id(unicode): event id
@param event_elt(domish.Element): event element
note that this element will be modified in place
@param creator(bool): True if client's profile is the creator of the node
"""
event_elt = item_elt.event
link_elt = event_elt.addElement("link")
link_elt["service"] = service.full()
link_elt["node"] = node
link_elt["item"] = event_id
__, event_data = self._parseEventElt(event_elt)
name = event_data.get('name')
if 'image' in event_data:
extra = {'thumb_url': event_data['image']}
else:
extra = None
return self.host.plugins['LIST_INTEREST'].registerPubsub(
client, NS_EVENT, service, node, event_id, creator,
name=name, element=event_elt, extra=extra)
def _eventGet(self, service, node, id_="", profile_key=C.PROF_KEY_NONE):
service = jid.JID(service) if service else None
node = node if node else NS_EVENT
@ -289,10 +264,11 @@ class Events(object):
node = node or None
client = self.host.getClient(profile_key)
data["register"] = C.bool(data.get("register", C.BOOL_FALSE))
return self.eventCreate(client, timestamp, data, service, node, id_ or NS_EVENT)
return defer.ensureDeferred(
self.eventCreate(client, timestamp, data, service, node, id_ or NS_EVENT)
)
@defer.inlineCallbacks
def eventCreate(self, client, timestamp, data, service, node=None, event_id=NS_EVENT):
async def eventCreate(self, client, timestamp, data, service, node=None, event_id=NS_EVENT):
"""Create or replace an event
@param service(jid.JID, None): PubSub service
@ -341,8 +317,8 @@ class Events(object):
del data[k]
if key not in data:
# FIXME: affiliate invitees
uri_node = yield self._p.createNode(client, service)
yield self._p.setConfiguration(
uri_node = await self._p.createNode(client, service)
await self._p.setConfiguration(
client,
service,
uri_node,
@ -372,17 +348,23 @@ class Events(object):
item_elt = pubsub.Item(id=event_id, payload=event_elt)
try:
# TODO: check auto-create, no need to create node first if available
node = yield self._p.createNode(client, service, nodeIdentifier=node)
node = await self._p.createNode(client, service, nodeIdentifier=node)
except error.StanzaError as e:
if e.condition == "conflict":
log.debug(_("requested node already exists"))
yield self._p.publish(client, service, node, items=[item_elt])
await self._p.publish(client, service, node, items=[item_elt])
if register:
yield self.register(
client, None, {}, service, node, event_id, item_elt, creator=True)
defer.returnValue(node)
extra = {}
self.onInvitationPreflight(
client, "", extra, service, node, event_id, item_elt
)
await self.host.plugins['LIST_INTEREST'].registerPubsub(
client, NS_EVENT, service, node, event_id, True,
extra.pop("name", ""), extra.pop("element"), extra
)
return node
def _eventModify(self, service, node, id_, timestamp_update, data_update,
profile_key=C.PROF_KEY_NONE):
@ -390,12 +372,14 @@ class Events(object):
if not node:
raise ValueError(_("missing node"))
client = self.host.getClient(profile_key)
return self.eventModify(
client, service, node, id_ or NS_EVENT, timestamp_update or None, data_update
return defer.ensureDeferred(
self.eventModify(
client, service, node, id_ or NS_EVENT, timestamp_update or None,
data_update
)
)
@defer.inlineCallbacks
def eventModify(
async def eventModify(
self, client, service, node, id_=NS_EVENT, timestamp_update=None, data_update=None
):
"""Update an event
@ -403,13 +387,13 @@ class Events(object):
Similar as create instead that it update existing item instead of
creating or replacing it. Params are the same as for [eventCreate].
"""
event_timestamp, event_metadata = yield self.eventGet(client, service, node, id_)
event_timestamp, event_metadata = await self.eventGet(client, service, node, id_)
new_timestamp = event_timestamp if timestamp_update is None else timestamp_update
new_data = event_metadata
if data_update:
for k, v in data_update.items():
new_data[k] = v
yield self.eventCreate(client, new_timestamp, new_data, service, node, id_)
await self.eventCreate(client, new_timestamp, new_data, service, node, id_)
def _eventsListSerialise(self, events):
for timestamp, data in events:
@ -543,52 +527,40 @@ class Events(object):
invitees[item["id"]] = data
defer.returnValue(invitees)
def _invite(self, invitee_jid, service, node, item_id, profile):
client = self.host.getClient(profile)
service = jid.JID(service) if service else None
node = node or None
item_id = item_id or None
invitee_jid = jid.JID(invitee_jid)
return self.invite(client, invitee_jid, service, node, item_id)
@defer.inlineCallbacks
def invite(self, client, invitee_jid, service, node, item_id=NS_EVENT):
"""Invite an entity to the event
This will set permission to let the entity access everything needed
@pararm invitee_jid(jid.JID): entity to invite
@param service(jid.JID, None): pubsub service
None to use client's PEP
@param node(unicode): event node
@param item_id(unicode): event id
"""
# FIXME: handle name and extra
name = ''
extra = {}
async def invitePreflight(
self,
client: SatXMPPEntity,
invitee_jid: jid.JID,
service: jid.JID,
node: str,
item_id: Optional[str] = None,
name: str = '',
extra: Optional[dict] = None,
) -> None:
if self._b is None:
raise exceptions.FeatureNotFound(
_('"XEP-0277" (blog) plugin is needed for this feature')
)
if item_id is None:
item_id = NS_EVENT
item_id = extra["default_item_id"] = NS_EVENT
# first we authorize our invitee to see the nodes of interest
yield self._p.setNodeAffiliations(client, service, node, {invitee_jid: "member"})
log.debug(_("affiliation set on event node"))
__, event_data = yield self.eventGet(client, service, node, item_id)
__, event_data = await self.eventGet(client, service, node, item_id)
log.debug(_("got event data"))
invitees_service = jid.JID(event_data["invitees_service"])
invitees_node = event_data["invitees_node"]
blog_service = jid.JID(event_data["blog_service"])
blog_node = event_data["blog_node"]
yield self._p.setNodeAffiliations(
await self._p.setNodeAffiliations(
client, invitees_service, invitees_node, {invitee_jid: "publisher"}
)
log.debug(_("affiliation set on invitee node"))
yield self._p.setNodeAffiliations(
log.debug(
f"affiliation set on invitee node (jid: {invitees_service}, "
f"node: {invitees_node!r})"
)
await self._p.setNodeAffiliations(
client, blog_service, blog_node, {invitee_jid: "member"}
)
blog_items, __ = yield self._b.mbGet(client, blog_service, blog_node, None)
blog_items, __ = await self._b.mbGet(client, blog_service, blog_node, None)
for item in blog_items:
try:
@ -601,15 +573,15 @@ class Events(object):
)
)
else:
yield self._p.setNodeAffiliations(
await self._p.setNodeAffiliations(
client, comments_service, comments_node, {invitee_jid: "publisher"}
)
log.debug(_("affiliation set on blog and comments nodes"))
# now we send the invitation
pubsub_invitation = self.host.plugins['INVITATION']
pubsub_invitation.sendPubsubInvitation(client, invitee_jid, service, node,
item_id, name, extra)
def _invite(self, invitee_jid, service, node, item_id, profile):
return self.host.plugins["PUBSUB_INVITATION"]._sendPubsubInvitation(
invitee_jid, service, node, item_id or NS_EVENT, profile_key=profile
)
def _inviteByEmail(self, service, node, id_=NS_EVENT, email="", emails_extra=None,
name="", host_name="", language="", url_template="",
@ -631,12 +603,11 @@ class Events(object):
):
value = locals()[key]
kwargs[key] = str(value)
return self.inviteByEmail(
return defer.ensureDeferred(self.inviteByEmail(
client, jid.JID(service) if service else None, node, id_ or NS_EVENT, **kwargs
)
))
@defer.inlineCallbacks
def inviteByEmail(self, client, service, node, id_=NS_EVENT, **kwargs):
async def inviteByEmail(self, client, service, node, id_=NS_EVENT, **kwargs):
"""High level method to create an email invitation to an event
@param service(unicode, None): PubSub service
@ -656,11 +627,37 @@ class Events(object):
"pubsub", path=service.full(), node=node, item=id_
)
kwargs["extra"] = {"event_uri": event_uri}
invitation_data = yield self._i.create(**kwargs)
invitation_data = await self._i.create(**kwargs)
invitee_jid = invitation_data["jid"]
log.debug(_("invitation created"))
# now that we have a jid, we can send normal invitation
yield self.invite(client, invitee_jid, service, node, id_)
await self.invite(client, invitee_jid, service, node, id_)
def onInvitationPreflight(
self,
client: SatXMPPEntity,
name: str,
extra: dict,
service: jid.JID,
node: str,
item_id: Optional[str],
item_elt: domish.Element
) -> None:
event_elt = item_elt.event
link_elt = event_elt.addElement("link")
link_elt["service"] = service.full()
link_elt["node"] = node
link_elt["item"] = item_id
__, event_data = self._parseEventElt(event_elt)
try:
name = event_data["name"]
except KeyError:
pass
else:
extra["name"] = name
if 'image' in event_data:
extra["thumb_url"] = event_data['image']
extra["element"] = event_elt
@implementer(iwokkel.IDisco)

View file

@ -16,15 +16,18 @@
# 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 Optional
from zope.interface import implementer
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
from wokkel import disco, iwokkel
from sat.core.i18n import _
from sat.core import exceptions
from sat.core.constants import Const as C
from sat.core.log import getLogger
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from wokkel import disco, iwokkel
from zope.interface import implementer
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
from sat.core.xmpp import SatXMPPEntity
from sat.tools import utils
log = getLogger(__name__)
@ -123,16 +126,25 @@ class Invitation(object):
invitation_elt['thumb_url'] = thumb_url
return mess_data, invitation_elt
def sendPubsubInvitation(self, client, invitee_jid, service, node,
item_id, name, extra):
def sendPubsubInvitation(
self,
client: SatXMPPEntity,
invitee_jid: jid.JID,
service: jid.JID,
node: str,
item_id: Optional[str],
name: Optional[str],
extra: Optional[dict]
) -> None:
"""Send an pubsub invitation in a <message> stanza
@param invitee_jid(jid.JID): entitee to send invitation to
@param service(jid.JID): pubsub service
@param node(unicode): pubsub node
@param item_id(unicode): pubsub id
@param name(unicode, None): see [_generateBaseInvitation]
@param extra(dict, None): see [_generateBaseInvitation]
@param invitee_jid: entitee to send invitation to
@param service: pubsub service
@param node: pubsub node
@param item_id: pubsub id
None when the invitation is for a whole node
@param name: see [_generateBaseInvitation]
@param extra: see [_generateBaseInvitation]
"""
if extra is None:
extra = {}
@ -141,8 +153,22 @@ class Invitation(object):
pubsub_elt = invitation_elt.addElement("pubsub")
pubsub_elt["service"] = service.full()
pubsub_elt["node"] = node
pubsub_elt["item"] = item_id
return client.send(mess_data["xml"])
if item_id is None:
try:
namespace = extra.pop("namespace")
except KeyError:
raise exceptions.DataError('"namespace" key is missing in "extra" data')
node_data_elt = pubsub_elt.addElement("node_data")
node_data_elt["namespace"] = namespace
try:
node_data_elt.addChild(extra["element"])
except KeyError:
pass
else:
pubsub_elt["item"] = item_id
if "element" in extra:
invitation_elt.addChild(extra.pop("element"))
client.send(mess_data["xml"])
async def sendFileSharingInvitation(
self, client, invitee_jid, service, repos_type=None, namespace=None, path=None,
@ -207,53 +233,61 @@ class Invitation(object):
file_sharing_elt["path"] = path
client.send(mess_data["xml"])
@defer.inlineCallbacks
def _parsePubsubElt(self, client, pubsub_elt):
async def _parsePubsubElt(self, client, pubsub_elt):
try:
service = jid.JID(pubsub_elt["service"])
node = pubsub_elt["node"]
item_id = pubsub_elt.getAttribute("item")
except (RuntimeError, KeyError):
log.warning(_("Bad invitation, ignoring"))
raise exceptions.DataError
raise exceptions.DataError("Bad invitation, ignoring")
try:
items, metadata = yield self._p.getItems(client, service, node,
item_ids=[item_id])
except Exception as e:
log.warning(_("Can't get item linked with invitation: {reason}").format(
reason=e))
try:
item_elt = items[0]
except IndexError:
log.warning(_("Invitation was linking to a non existing item"))
raise exceptions.DataError
item_id = pubsub_elt.getAttribute("item")
try:
namespace = item_elt.firstChildElement().uri
except Exception as e:
log.warning(_("Can't retrieve namespace of invitation: {reason}").format(
reason = e))
raise exceptions.DataError
if item_id is not None:
try:
items, metadata = await self._p.getItems(
client, service, node, item_ids=[item_id]
)
except Exception as e:
log.warning(_("Can't get item linked with invitation: {reason}").format(
reason=e))
try:
item_elt = items[0]
except IndexError:
log.warning(_("Invitation was linking to a non existing item"))
raise exceptions.DataError
args = [service, node, item_id, item_elt]
defer.returnValue((namespace, args))
try:
namespace = item_elt.firstChildElement().uri
except Exception as e:
log.warning(_("Can't retrieve namespace of invitation: {reason}").format(
reason = e))
raise exceptions.DataError
def _parseFileSharingElt(self, client, file_sharing_elt):
args = [service, node, item_id, item_elt]
else:
try:
node_data_elt = next(pubsub_elt.elements((NS_INVITATION, "node_data")))
except StopIteration:
raise exceptions.DataError("Bad invitation, ignoring")
namespace = node_data_elt['namespace']
args = [service, node, None, node_data_elt]
return namespace, args
async def _parseFileSharingElt(self, client, file_sharing_elt):
try:
service = jid.JID(file_sharing_elt["service"])
except (RuntimeError, KeyError):
log.warning(_("Bad invitation, ignoring"))
raise exceptions.DataError
repos_type = file_sharing_elt.getAttribute("type", "files")
namespace = file_sharing_elt.getAttribute("namespace")
sharing_ns = file_sharing_elt.getAttribute("namespace")
path = file_sharing_elt.getAttribute("path")
args = [service, repos_type, namespace, path]
args = [service, repos_type, sharing_ns, path]
ns_fis = self.host.getNamespace("fis")
return ns_fis, args
@defer.inlineCallbacks
def onInvitation(self, message_elt, client):
async def onInvitation(self, message_elt, client):
log.debug("invitation received [{profile}]".format(profile=client.profile))
invitation_elt = message_elt.invitation
@ -275,7 +309,7 @@ class Invitation(object):
xml = elt.toXml()))
continue
try:
namespace, args = yield method(client, elt)
namespace, args = await method(client, elt)
except exceptions.DataError:
log.warning("Can't parse invitation element: {xml}".format(
xml = elt.toXml()))
@ -288,7 +322,7 @@ class Invitation(object):
'No handler for namespace "{namespace}", invitation ignored')
.format(namespace=namespace))
else:
cb(client, name, extra, *args)
await utils.asDeferred(cb, client, namespace, name, extra, *args)
@implementer(iwokkel.IDisco)
@ -299,7 +333,10 @@ class PubsubInvitationHandler(XMPPHandler):
def connectionInitialized(self):
self.xmlstream.addObserver(
INVITATION, self.plugin_parent.onInvitation, client=self.parent
INVITATION,
lambda message_elt: defer.ensureDeferred(
self.plugin_parent.onInvitation(message_elt, client=self.parent)
),
)
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):

View file

@ -19,6 +19,7 @@
from sat.core.i18n import _
from sat.core.constants import Const as C
from sat.core.log import getLogger
from sat.core.xmpp import SatXMPPEntity
from sat.tools.common import data_format
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
@ -69,7 +70,17 @@ class FileSharingInvitation:
extra=extra)
)
def onInvitation(self, client, name, extra, service, repos_type, namespace, path):
def onInvitation(
self,
client: SatXMPPEntity,
namespace: str,
name: str,
extra: dict,
service: jid.JID,
repos_type: str,
sharing_ns: str,
path: str
):
if repos_type == "files":
type_human = _("file sharing")
elif repos_type == "photos":
@ -81,11 +92,12 @@ class FileSharingInvitation:
type_human = _("file sharing")
log.info(_(
'{profile} has received an invitation for a files repository ({type_human}) '
'with namespace {namespace!r} at path [{path}]').format(
profile=client.profile, type_human=type_human, namespace=namespace, path=path)
'with namespace {sharing_ns!r} at path [{path}]').format(
profile=client.profile, type_human=type_human, sharing_ns=sharing_ns,
path=path)
)
return defer.ensureDeferred(
self.host.plugins['LIST_INTEREST'].registerFileSharing(
client, service, repos_type, namespace, path, name, extra
client, service, repos_type, sharing_ns, path, name, extra
)
)

View file

@ -0,0 +1,169 @@
#!/usr/bin/env python3
# SàT plugin to send invitations for Pubsub
# Copyright (C) 2009-2021 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 Optional
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from twisted.words.xish import domish
from sat.core.i18n import _
from sat.core.constants import Const as C
from sat.core.log import getLogger
from sat.core.xmpp import SatXMPPEntity
from sat.tools import utils
from sat.tools.common import data_format
log = getLogger(__name__)
PLUGIN_INFO = {
C.PI_NAME: "Pubsub Invitation",
C.PI_IMPORT_NAME: "PUBSUB_INVITATION",
C.PI_TYPE: "EXP",
C.PI_PROTOCOLS: [],
C.PI_DEPENDENCIES: ["XEP-0060", "INVITATION"],
C.PI_RECOMMENDATIONS: [],
C.PI_MAIN: "PubsubInvitation",
C.PI_HANDLER: "no",
C.PI_DESCRIPTION: _("Invitations for pubsub based features"),
}
class PubsubInvitation:
def __init__(self, host):
log.info(_("Pubsub Invitation plugin initialization"))
self.host = host
self._p = host.plugins["XEP-0060"]
# namespace to handler map
self._ns_handler = {}
host.bridge.addMethod(
"psInvite",
".plugin",
in_sign="sssssss",
out_sign="",
method=self._sendPubsubInvitation,
async_=True
)
def register(
self,
namespace: str,
handler
) -> None:
self._ns_handler[namespace] = handler
self.host.plugins["INVITATION"].registerNamespace(namespace, self.onInvitation)
def _sendPubsubInvitation(
self, invitee_jid_s, service_s, node, item_id=None,
name=None, extra_s='', profile_key=C.PROF_KEY_NONE):
client = self.host.getClient(profile_key)
invitee_jid = jid.JID(invitee_jid_s)
service = jid.JID(service_s)
extra = data_format.deserialise(extra_s)
return defer.ensureDeferred(
self.invite(
client,
invitee_jid,
service,
node,
item_id or None,
name=name or None,
extra=extra
)
)
async def invite(
self,
client: SatXMPPEntity,
invitee_jid: jid.JID,
service: jid.JID,
node: str,
item_id: Optional[str] = None,
name: str = '',
extra: Optional[dict] = None,
) -> None:
if extra is None:
extra = {}
else:
namespace = extra.get("namespace")
if namespace:
try:
handler = self._ns_handler[namespace]
preflight = handler.invitePreflight
except KeyError:
pass
except AttributeError:
log.debug(f"no invitePreflight method found for {namespace!r}")
else:
await utils.asDeferred(
preflight,
client, invitee_jid, service, node, item_id, name, extra
)
if item_id is None:
item_id = extra.pop("default_item_id", None)
# we authorize our invitee to see the nodes of interest
await self._p.setNodeAffiliations(client, service, node, {invitee_jid: "member"})
log.debug(f"affiliation set on {service}'s {node!r} node")
# now we send the invitation
self.host.plugins["INVITATION"].sendPubsubInvitation(
client,
invitee_jid,
service,
node,
item_id,
name=name or None,
extra=extra
)
async def onInvitation(
self,
client: SatXMPPEntity,
namespace: str,
name: str,
extra: dict,
service: jid.JID,
node: str,
item_id: Optional[str],
item_elt: domish.Element
) -> None:
if extra is None:
extra = {}
try:
handler = self._ns_handler[namespace]
preflight = handler.onInvitationPreflight
except KeyError:
pass
except AttributeError:
log.debug(f"no onInvitationPreflight method found for {namespace!r}")
else:
await utils.asDeferred(
preflight,
client, namespace, name, extra, service, node, item_id, item_elt
)
if item_id is None:
item_id = extra.pop("default_item_id", None)
creator = extra.pop("creator", False)
element = extra.pop("element", None)
if not name:
name = extra.pop("name", "")
return self.host.plugins['LIST_INTEREST'].registerPubsub(
client, namespace, service, node, item_id, creator,
name, element, extra)