plugin events: refactored invitation and personal lists logic:

- invitation logic has been moved to a new generic "plugin_exp_invitation" plugin
- plugin_misc_invitations has be rename "plugin_exp_email_invitation" to avoid confusion
- personal event list has be refactored to use a new experimental "list of interest", which regroup all interestings items, events or other ones
This commit is contained in:
Goffi 2019-04-14 08:21:51 +02:00
commit 29b2a7cb11
4 changed files with 386 additions and 134 deletions

175
sat/plugins/plugin_exp_events.py Executable file → Normal file
View file

@ -17,12 +17,11 @@
# 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 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
log = getLogger(__name__)
from sat.tools import utils
from sat.tools.common import uri as xmpp_uri
from sat.tools.common import date_utils
@ -32,29 +31,24 @@ from twisted.words.xish import domish
from wokkel import disco, iwokkel
from zope.interface import implements
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
from wokkel import pubsub
import shortuuid
log = getLogger(__name__)
PLUGIN_INFO = {
C.PI_NAME: "Event plugin",
C.PI_NAME: "Events",
C.PI_IMPORT_NAME: "EVENTS",
C.PI_TYPE: "EXP",
C.PI_PROTOCOLS: [],
C.PI_DEPENDENCIES: ["XEP-0060"],
C.PI_RECOMMENDATIONS: ["INVITATIONS", "XEP-0277"],
C.PI_DEPENDENCIES: [u"XEP-0060", u"INVITATION", u"LIST_INTEREST"],
C.PI_RECOMMENDATIONS: ["XEP-0277", "EMAIL_INVITATION"],
C.PI_MAIN: "Events",
C.PI_HANDLER: "yes",
C.PI_DESCRIPTION: _("""Experimental implementation of XMPP events management"""),
C.PI_DESCRIPTION: _(u"""Experimental implementation of XMPP events management"""),
}
NS_EVENT = "org.salut-a-toi.event:0"
NS_EVENT_LIST = NS_EVENT + "#list"
NS_EVENT_INVIT = NS_EVENT + "#invitation"
INVITATION = '/message[@type="chat"]/invitation[@xmlns="{ns_invit}"]'.format(
ns_invit=NS_EVENT_INVIT
)
class Events(object):
@ -64,8 +58,10 @@ class Events(object):
log.info(_(u"Event plugin initialization"))
self.host = host
self._p = self.host.plugins["XEP-0060"]
self._i = self.host.plugins.get("INVITATIONS")
self._i = self.host.plugins.get("EMAIL_INVITATION")
self._b = self.host.plugins.get("XEP-0277")
self.host.plugins[u"INVITATION"].registerNamespace(NS_EVENT,
self.register)
host.bridge.addMethod(
"eventGet",
".plugin",
@ -229,11 +225,12 @@ class Events(object):
items, metadata = yield self._p.getItems(client, service, node, item_ids=[id_])
try:
event_elt = next(items[0].elements(NS_EVENT, u"event"))
except StopIteration:
raise exceptions.NotFound(_(u"No event element has been found"))
except IndexError:
raise exceptions.NotFound(_(u"No event with this id has been found"))
defer.returnValue(event_elt)
@defer.inlineCallbacks
def register(self, client, service, node, event_id, event_elt, creator=False):
"""register evenement in personal events list
@ -244,32 +241,13 @@ class Events(object):
note that this element will be modified in place
@param creator(bool): True if client's profile is the creator of the node
"""
# we save a link to the event in our local list
try:
# TODO: check auto-create, no need to create node first if available
options = {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST}
yield self._p.createNode(
client,
client.jid.userhostJID(),
nodeIdentifier=NS_EVENT_LIST,
options=options,
)
except error.StanzaError as e:
if e.condition == u"conflict":
log.debug(_(u"requested node already exists"))
link_elt = event_elt.addElement((NS_EVENT_LIST, "link"))
link_elt = event_elt.addElement("link")
link_elt["service"] = service.full()
link_elt["node"] = node
link_elt["item"] = event_id
item_id = xmpp_uri.buildXMPPUri(
u"pubsub", path=service.full(), node=node, item=event_id
)
if creator:
event_elt["creator"] = "true"
item_elt = pubsub.Item(id=item_id, payload=event_elt)
yield self._p.publish(
client, client.jid.userhostJID(), NS_EVENT_LIST, items=[item_elt]
)
return self.host.plugins[u'LIST_INTEREST'].registerPubsub(
client, NS_EVENT, service, node, event_id, creator,
element=event_elt)
def _eventGet(self, service, node, id_=u"", profile_key=C.PROF_KEY_NONE):
service = jid.JID(service) if service else None
@ -396,17 +374,11 @@ class Events(object):
yield self.register(client, service, node, event_id, event_elt, creator=True)
defer.returnValue(node)
def _eventModify(
self,
service,
node,
id_,
timestamp_update,
data_update,
profile_key=C.PROF_KEY_NONE,
):
def _eventModify(self, service, node, id_, timestamp_update, data_update,
profile_key=C.PROF_KEY_NONE):
service = jid.JID(service) if service else None
node = node if node else NS_EVENT
if not node:
raise ValueError(_(u"missing node"))
client = self.host.getClient(profile_key)
return self.eventModify(
client, service, node, id_ or NS_EVENT, timestamp_update or None, data_update
@ -444,24 +416,24 @@ class Events(object):
return d
@defer.inlineCallbacks
def eventsList(self, client, service, node):
def eventsList(self, client, service, node=None):
"""Retrieve list of registered events
@return list(tuple(int, dict)): list of events (timestamp + metadata)
"""
if not node:
node = NS_EVENT_LIST
items = yield self._p.getItems(client, service, node)
items, metadata = yield self.host.plugins[u'LIST_INTEREST'].listInterests(
client, service, node, namespace=NS_EVENT)
events = []
for item in items[0]:
for item in items:
try:
event_elt = next(item.elements(NS_EVENT, u"event"))
event_elt = next(item.interest.pubsub.elements(NS_EVENT, u"event"))
except IndexError:
log.error(
log.warning(
_(u"No event found in item {item_id}").format(item_id=item["id"])
)
timestamp, data = self._parseEventElt(event_elt)
events.append((timestamp, data))
else:
timestamp, data = self._parseEventElt(event_elt)
events.append((timestamp, data))
defer.returnValue(events)
def _eventInviteeGet(self, service, node, profile_key):
@ -479,12 +451,12 @@ class Events(object):
@return (dict): a dict with current attendance status,
an empty dict is returned if nothing has been answered yed
"""
items, metadata = yield self._p.getItems(
client, service, node, item_ids=[client.jid.userhost()]
)
try:
items, metadata = yield self._p.getItems(
client, service, node, item_ids=[client.jid.userhost()]
)
event_elt = next(items[0].elements(NS_EVENT, u"invitee"))
except IndexError:
except (exceptions.NotFound, IndexError):
# no item found, event data are not set yet
defer.returnValue({})
data = {}
@ -542,13 +514,9 @@ class Events(object):
event_elt = next(item.elements(NS_EVENT, u"invitee"))
except StopIteration:
# no item found, event data are not set yet
log.warning(
_(
u"no data found for {item_id} (service: {service}, node: {node})".format(
item_id=item["id"], service=service, node=node
)
)
)
log.warning(_(
u"no data found for {item_id} (service: {service}, node: {node})"
.format(item_id=item["id"], service=service, node=node)))
else:
data = {}
for key in (u"attend", u"guests"):
@ -559,30 +527,6 @@ class Events(object):
invitees[item["id"]] = data
defer.returnValue(invitees)
def sendMessageInvitation(self, client, invitee_jid, service, node, item_id):
"""Send an invitation in a <message> stanza
@param invitee_jid(jid.JID): entitee to send invitation to
@param service(jid.JID): pubsub service of the event
@param node(unicode): node of the event
@param item_id(unicode): id of the event
"""
mess_data = {
"from": client.jid,
"to": invitee_jid,
"uid": "",
"message": {},
"type": C.MESS_TYPE_CHAT,
"subject": {},
"extra": {},
}
client.generateMessageXML(mess_data)
event_elt = mess_data["xml"].addElement("invitation", NS_EVENT_INVIT)
event_elt["service"] = service.full()
event_elt["node"] = node
event_elt["item"] = item_id
client.send(mess_data["xml"])
def _invite(self, invitee_jid, service, node, item_id, profile):
client = self.host.getClient(profile)
service = jid.JID(service) if service else None
@ -644,23 +588,14 @@ class Events(object):
log.debug(_(u"affiliation set on blog and comments nodes"))
# now we send the invitation
self.sendMessageInvitation(client, invitee_jid, service, node, item_id)
pubsub_invitation = self.host.plugins[u'PUBSUB_INVITATION']
pubsub_invitation.sendPubsubInvitation(client, invitee_jid, service, node,
item_id)
def _inviteByEmail(
self,
service,
node,
id_=NS_EVENT,
email=u"",
emails_extra=None,
name=u"",
host_name=u"",
language=u"",
url_template=u"",
message_subject=u"",
message_body=u"",
profile_key=C.PROF_KEY_NONE,
):
def _inviteByEmail(self, service, node, id_=NS_EVENT, email=u"", emails_extra=None,
name=u"", host_name=u"", language=u"", url_template=u"",
message_subject=u"", message_body=u"",
profile_key=C.PROF_KEY_NONE):
client = self.host.getClient(profile_key)
kwargs = {
u"profile": client.profile,
@ -708,19 +643,6 @@ class Events(object):
# now that we have a jid, we can send normal invitation
yield self.invite(client, invitee_jid, service, node, id_)
@defer.inlineCallbacks
def onInvitation(self, message_elt, client):
invitation_elt = message_elt.invitation
try:
service = jid.JID(invitation_elt["service"])
node = invitation_elt["node"]
event_id = invitation_elt["item"]
except (RuntimeError, KeyError):
log.warning(_(u"Bad invitation: {xml}").format(xml=message_elt.toXml()))
event_elt = yield self.getEventElement(client, service, node, event_id)
yield self.register(client, service, node, event_id, event_elt, creator=False)
class EventsHandler(XMPPHandler):
implements(iwokkel.IDisco)
@ -728,20 +650,9 @@ class EventsHandler(XMPPHandler):
def __init__(self, plugin_parent):
self.plugin_parent = plugin_parent
@property
def host(self):
return self.plugin_parent.host
def connectionInitialized(self):
self.xmlstream.addObserver(
INVITATION, self.plugin_parent.onInvitation, client=self.parent
)
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
return [
disco.DiscoFeature(NS_EVENT),
disco.DiscoFeature(NS_EVENT_LIST),
disco.DiscoFeature(NS_EVENT_INVIT),
]
def getDiscoItems(self, requestor, target, nodeIdentifier=""):

View file

@ -0,0 +1,187 @@
#!/usr/bin/env python2
# -*- coding: utf-8 -*-
# SAT plugin to detect language (experimental)
# Copyright (C) 2009-2019 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 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 implements
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
log = getLogger(__name__)
PLUGIN_INFO = {
C.PI_NAME: "Invitation",
C.PI_IMPORT_NAME: "INVITATION",
C.PI_TYPE: "EXP",
C.PI_PROTOCOLS: [],
C.PI_DEPENDENCIES: ["XEP-0060"],
C.PI_RECOMMENDATIONS: [],
C.PI_MAIN: "Invitation",
C.PI_HANDLER: "yes",
C.PI_DESCRIPTION: _(u"Experimental handling of invitations"),
}
NS_INVITATION = u"https://salut-a-toi/protocol/invitation:0"
INVITATION = '/message/invitation[@xmlns="{ns_invit}"]'.format(
ns_invit=NS_INVITATION
)
NS_INVITATION_LIST = NS_INVITATION + u"#list"
class Invitation(object):
def __init__(self, host):
log.info(_(u"Invitation plugin initialization"))
self.host = host
self._p = self.host.plugins["XEP-0060"]
# map from namespace of the invitation to callback handling it
self._ns_cb = {}
def getHandler(self, client):
return PubsubInvitationHandler(self)
def registerNamespace(self, namespace, callback):
"""Register a callback for a namespace
@param namespace(unicode): namespace handled
@param callback(callbable): method handling the invitation
For pubsub invitation, it will be called with following arguments:
- client
- service(jid.JID): pubsub service jid
- node(unicode): pubsub node
- item_id(unicode, None): pubsub item id
- item_elt(domish.Element): item of the invitation
@raise exceptions.ConflictError: this namespace is already registered
"""
if namespace in self._ns_cb:
raise exceptions.ConflictError(
u"invitation namespace {namespace} is already register with {callback}"
.format(namespace=namespace, callback=self._ns_cb[namespace]))
self._ns_cb[namespace] = callback
def sendPubsubInvitation(self, client, invitee_jid, service, node,
item_id):
"""Send an 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
"""
mess_data = {
"from": client.jid,
"to": invitee_jid,
"uid": "",
"message": {},
"type": C.MESS_TYPE_CHAT,
"subject": {},
"extra": {},
}
client.generateMessageXML(mess_data)
invitation_elt = mess_data["xml"].addElement("invitation", NS_INVITATION)
pubsub_elt = invitation_elt.addElement(u"pubsub")
pubsub_elt[u"service"] = service.full()
pubsub_elt[u"node"] = node
pubsub_elt[u"item"] = item_id
client.send(mess_data[u"xml"])
@defer.inlineCallbacks
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(_(u"Bad invitation, ignoring"))
raise exceptions.DataError
try:
items, metadata = yield self._p.getItems(client, service, node,
item_ids=[item_id])
except Exception as e:
log.warning(_(u"Can't get item linked with invitation: {reason}").format(
reason=e))
try:
item_elt = items[0]
except IndexError:
log.warning(_(u"Invitation was linking to a non existing item"))
raise exceptions.DataError
try:
namespace = item_elt.firstChildElement().uri
except Exception as e:
log.warning(_(u"Can't retrieve namespace of invitation: {reason}").format(
reason = e))
raise exceptions.DataError
args = [service, node, item_id, item_elt]
defer.returnValue((namespace, args))
@defer.inlineCallbacks
def onInvitation(self, message_elt, client):
invitation_elt = message_elt.invitation
for elt in invitation_elt.elements():
if elt.uri != NS_INVITATION:
log.warning(u"unexpected element: {xml}".format(xml=elt.toXml()))
continue
if elt.name == u"pubsub":
method = self._parsePubsubElt
else:
log.warning(u"not implemented invitation element: {xml}".format(
xml = elt.toXml()))
continue
try:
namespace, args = yield method(client, elt)
except exceptions.DataError:
log.warning(u"Can't parse invitation element: {xml}".format(
xml = elt.toXml()))
continue
try:
cb = self._ns_cb[namespace]
except KeyError:
log.warning(_(u'No handler for namespace "{namespace}", invitation ignored')
.format(namespace=namespace))
else:
cb(client, *args)
class PubsubInvitationHandler(XMPPHandler):
implements(iwokkel.IDisco)
def __init__(self, plugin_parent):
self.plugin_parent = plugin_parent
def connectionInitialized(self):
self.xmlstream.addObserver(
INVITATION, self.plugin_parent.onInvitation, client=self.parent
)
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
return [
disco.DiscoFeature(NS_INVITATION),
]
def getDiscoItems(self, requestor, target, nodeIdentifier=""):
return []

View file

@ -0,0 +1,154 @@
#!/usr/bin/env python2
# -*- coding: utf-8 -*-
# SAT plugin to detect language (experimental)
# Copyright (C) 2009-2019 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 sat.core.i18n import _
from sat.core.constants import Const as C
from sat.core.log import getLogger
from wokkel import disco, iwokkel, pubsub
from zope.interface import implements
from twisted.internet import defer
from twisted.words.protocols.jabber import error as jabber_error
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
from twisted.words.xish import domish
log = getLogger(__name__)
PLUGIN_INFO = {
C.PI_NAME: "List of Interest",
C.PI_IMPORT_NAME: "LIST_INTEREST",
C.PI_TYPE: "EXP",
C.PI_PROTOCOLS: [],
C.PI_DEPENDENCIES: ["XEP-0060"],
C.PI_RECOMMENDATIONS: [],
C.PI_MAIN: "ListInterest",
C.PI_HANDLER: "yes",
C.PI_DESCRIPTION: _(u"Experimental handling of interesting XMPP locations"),
}
NS_LIST_INTEREST = "https://salut-a-toi/protocol/list-interest:0"
class ListInterest(object):
namespace = NS_LIST_INTEREST
def __init__(self, host):
log.info(_(u"List of Interest plugin initialization"))
self.host = host
self._p = self.host.plugins["XEP-0060"]
def getHandler(self, client):
return ListInterestHandler(self)
@defer.inlineCallbacks
def createNode(self, client):
try:
# TODO: check auto-create, no need to create node first if available
options = {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST}
yield self._p.createNode(
client,
client.jid.userhostJID(),
nodeIdentifier=NS_LIST_INTEREST,
options=options,
)
except jabber_error.StanzaError as e:
if e.condition == u"conflict":
log.debug(_(u"requested node already exists"))
@defer.inlineCallbacks
def registerPubsub(self, client, namespace, service, node, item_id=None,
creator=False, name=None, element=None):
"""Register an interesting element in personal list
@param namespace(unicode): namespace of the interest
this is used as a cache, to avoid the need to retrieve the item only to get
its namespace
@param service(jid.JID): pubsub service of the
@param node(unicode): target pubsub node
@param item_id(unicode, None): target pubsub id
@param creator(bool): True if client's profile is the creator of the node
This is used a cache, to avoid the need to retrieve affiliations
@param name(unicode, None): name of the interest
@param element(domish.Element, None): element to attach
may be used to cache some extra data
"""
yield self.createNode(client)
interest_elt = domish.Element((NS_LIST_INTEREST, u"interest"))
interest_elt[u"namespace"] = namespace
if name is not None:
interest_elt[u'name'] = name
pubsub_elt = interest_elt.addElement(u"pubsub")
pubsub_elt[u"service"] = service.full()
pubsub_elt[u"node"] = node
if item_id is not None:
pubsub_elt[u"item"] = item_id
if creator:
pubsub_elt[u"creator"] = C.BOOL_TRUE
if element is not None:
pubsub_elt.addChild(element)
item_elt = pubsub.Item(payload=interest_elt)
yield self._p.publish(
client, client.jid.userhostJID(), NS_LIST_INTEREST, items=[item_elt]
)
@defer.inlineCallbacks
def listInterests(self, client, service=None, node=None, namespace=None):
"""Retrieve list of interests
@param service(jid.JID, None): service to use
None to use own PEP
@param node(unicode, None): node to use
None to use default node
@param namespace(unicode, None): filter interests of this namespace
None to retrieve all interests
@return: same as [XEP_0060.getItems]
"""
# TODO: if a MAM filter were available, it would improve performances
if not node:
node = NS_LIST_INTEREST
items, metadata = yield self._p.getItems(client, service, node)
if namespace is not None:
filtered_items = []
for item in items:
try:
interest_elt = next(item.elements(NS_LIST_INTEREST, u"interest"))
except StopIteration:
log.warning(_(u"Missing interest element: {xml}").format(
xml=interest_elt.toXml()))
continue
if interest_elt.getAttribute(u"namespace") == namespace:
filtered_items.append(item)
items = filtered_items
defer.returnValue((items, metadata))
class ListInterestHandler(XMPPHandler):
implements(iwokkel.IDisco)
def __init__(self, plugin_parent):
self.plugin_parent = plugin_parent
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
return [
disco.DiscoFeature(NS_LIST_INTEREST),
]
def getDiscoItems(self, requestor, target, nodeIdentifier=""):
return []

View file

@ -0,0 +1,409 @@
#!/usr/bin/env python2
# -*- coding: utf-8 -*-
# SAT plugin for file tansfer
# Copyright (C) 2009-2019 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 shortuuid
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from twisted.words.protocols.jabber import error
from sat.core.i18n import _, D_
from sat.core.constants import Const as C
from sat.core import exceptions
from sat.core.log import getLogger
from sat.tools import utils
from sat.tools.common import data_format
from sat.memory import persistent
from sat.tools import email as sat_email
log = getLogger(__name__)
PLUGIN_INFO = {
C.PI_NAME: "Invitations",
C.PI_IMPORT_NAME: "EMAIL_INVITATION",
C.PI_TYPE: C.PLUG_TYPE_MISC,
C.PI_DEPENDENCIES: ['XEP-0077'],
C.PI_RECOMMENDATIONS: ["IDENTITY"],
C.PI_MAIN: "InvitationsPlugin",
C.PI_HANDLER: "no",
C.PI_DESCRIPTION: _(u"""invitation of people without XMPP account""")
}
SUFFIX_MAX = 5
INVITEE_PROFILE_TPL = u"guest@@{uuid}"
KEY_ID = u'id'
KEY_JID = u'jid'
KEY_CREATED = u'created'
KEY_LAST_CONNECTION = u'last_connection'
KEY_GUEST_PROFILE = u'guest_profile'
KEY_PASSWORD = u'password'
KEY_EMAILS_EXTRA = u'emails_extra'
EXTRA_RESERVED = {KEY_ID, KEY_JID, KEY_CREATED, u'jid_', u'jid', KEY_LAST_CONNECTION,
KEY_GUEST_PROFILE, KEY_PASSWORD, KEY_EMAILS_EXTRA}
DEFAULT_SUBJECT = D_(u"You have been invited by {host_name} to {app_name}")
DEFAULT_BODY = D_(u"""Hello {name}!
You have received an invitation from {host_name} to participate to "{app_name}".
To join, you just have to click on the following URL:
{url}
Please note that this URL should not be shared with anybody!
If you want more details on {app_name}, you can check {app_url}.
Welcome!
""")
class InvitationsPlugin(object):
def __init__(self, host):
log.info(_(u"plugin Invitations initialization"))
self.host = host
self.invitations = persistent.LazyPersistentBinaryDict(u'invitations')
host.bridge.addMethod("invitationCreate", ".plugin", in_sign='sasssssssssa{ss}s',
out_sign='a{ss}',
method=self._create,
async=True)
host.bridge.addMethod("invitationGet", ".plugin", in_sign='s', out_sign='a{ss}',
method=self.get,
async=True)
host.bridge.addMethod("invitationModify", ".plugin", in_sign='sa{ss}b',
out_sign='',
method=self._modify,
async=True)
host.bridge.addMethod("invitationList", ".plugin", in_sign='s',
out_sign='a{sa{ss}}',
method=self._list,
async=True)
def checkExtra(self, extra):
if EXTRA_RESERVED.intersection(extra):
raise ValueError(
_(u"You can't use following key(s) in extra, they are reserved: {}")
.format(u', '.join(EXTRA_RESERVED.intersection(extra))))
def _create(self, email=u'', emails_extra=None, jid_=u'', password=u'', name=u'',
host_name=u'', language=u'', url_template=u'', message_subject=u'',
message_body=u'', extra=None, profile=u''):
# XXX: we don't use **kwargs here to keep arguments name for introspection with
# D-Bus bridge
if emails_extra is None:
emails_extra = []
if extra is None:
extra = {}
else:
extra = {unicode(k): unicode(v) for k,v in extra.iteritems()}
kwargs = {"extra": extra,
KEY_EMAILS_EXTRA: [unicode(e) for e in emails_extra]
}
# we need to be sure that values are unicode, else they won't be pickled correctly
# with D-Bus
for key in ("jid_", "password", "name", "host_name", "email", "language",
"url_template", "message_subject", "message_body", "profile"):
value = locals()[key]
if value:
kwargs[key] = unicode(value)
d = self.create(**kwargs)
def serialize(data):
data[KEY_JID] = data[KEY_JID].full()
return data
d.addCallback(serialize)
return d
@defer.inlineCallbacks
def create(self, **kwargs):
ur"""Create an invitation
This will create an XMPP account and a profile, and use a UUID to retrieve them.
The profile is automatically generated in the form guest@@[UUID], this way they
can be retrieved easily
**kwargs: keywords arguments which can have the following keys, unset values are
equivalent to None:
jid_(jid.JID, None): jid to use for invitation, the jid will be created using
XEP-0077
if the jid has no user part, an anonymous account will be used (no XMPP
account created in this case)
if None, automatically generate an account name (in the form
"invitation-[random UUID]@domain.tld") (note that this UUID is not the
same as the invitation one, as jid can be used publicly (leaking the
UUID), and invitation UUID give access to account.
in case of conflict, a suffix number is added to the account until a free
one if found (with a failure if SUFFIX_MAX is reached)
password(unicode, None): password to use (will be used for XMPP account and
profile)
None to automatically generate one
name(unicode, None): name of the invitee
will be set as profile identity if present
host_name(unicode, None): name of the host
email(unicode, None): email to send the invitation to
if None, no invitation email is sent, you can still associate email using
extra
if email is used, extra can't have "email" key
language(unicode): language of the invitee (used notabily to translate the
invitation)
TODO: not used yet
url_template(unicode, None): template to use to construct the invitation URL
use {uuid} as a placeholder for identifier
use None if you don't want to include URL (or if it is already specified
in custom message)
/!\ you must put full URL, don't forget https://
/!\ the URL will give access to the invitee account, you should warn in
message to not publish it publicly
message_subject(unicode, None): customised message body for the invitation
email
None to use default subject
uses the same substitution as for message_body
message_body(unicode, None): customised message body for the invitation email
None to use default body
use {name} as a place holder for invitee name
use {url} as a placeholder for the invitation url
use {uuid} as a placeholder for the identifier
use {app_name} as a placeholder for this software name
use {app_url} as a placeholder for this software official website
use {profile} as a placeholder for host's profile
use {host_name} as a placeholder for host's name
extra(dict, None): extra data to associate with the invitee
some keys are reserved:
- created (creation date)
if email argument is used, "email" key can't be used
profile(unicode, None): profile of the host (person who is inviting)
@return (dict[unicode, unicode]): dictionary with:
- UUID associated with the invitee (key: id)
- filled extra dictionary, as saved in the databae
"""
## initial checks
extra = kwargs.pop('extra', {})
if set(kwargs).intersection(extra):
raise ValueError(
_(u"You can't use following key(s) in both args and extra: {}").format(
u', '.join(set(kwargs).intersection(extra))))
self.checkExtra(extra)
email = kwargs.pop(u'email', None)
emails_extra = kwargs.pop(u'emails_extra', [])
if not email and emails_extra:
raise ValueError(
_(u'You need to provide a main email address before using emails_extra'))
if (email is not None
and not 'url_template' in kwargs
and not 'message_body' in kwargs):
raise ValueError(
_(u"You need to provide url_template if you use default message body"))
## uuid
log.info(_(u"creating an invitation"))
id_ = unicode(shortuuid.uuid())
## XMPP account creation
password = kwargs.pop(u'password', None)
if password is None:
password = utils.generatePassword()
assert password
# XXX: password is here saved in clear in database
# it is needed for invitation as the same password is used for profile
# and SàT need to be able to automatically open the profile with the uuid
# FIXME: we could add an extra encryption key which would be used with the uuid
# when the invitee is connecting (e.g. with URL). This key would not be
# saved and could be used to encrypt profile password.
extra[KEY_PASSWORD] = password
jid_ = kwargs.pop(u'jid_', None)
if not jid_:
domain = self.host.memory.getConfig(None, 'xmpp_domain')
if not domain:
# TODO: fallback to profile's domain
raise ValueError(_(u"You need to specify xmpp_domain in sat.conf"))
jid_ = u"invitation-{uuid}@{domain}".format(uuid=shortuuid.uuid(),
domain=domain)
jid_ = jid.JID(jid_)
if jid_.user:
# we don't register account if there is no user as anonymous login is then
# used
try:
yield self.host.plugins['XEP-0077'].registerNewAccount(jid_, password)
except error.StanzaError as e:
prefix = jid_.user
idx = 0
while e.condition == u'conflict':
if idx >= SUFFIX_MAX:
raise exceptions.ConflictError(_(u"Can't create XMPP account"))
jid_.user = prefix + '_' + unicode(idx)
log.info(_(u"requested jid already exists, trying with {}".format(
jid_.full())))
try:
yield self.host.plugins['XEP-0077'].registerNewAccount(jid_,
password)
except error.StanzaError as e:
idx += 1
else:
break
if e.condition != u'conflict':
raise e
log.info(_(u"account {jid_} created").format(jid_=jid_.full()))
## profile creation
extra[KEY_GUEST_PROFILE] = guest_profile = INVITEE_PROFILE_TPL.format(uuid=id_)
# profile creation should not fail as we generate unique name ourselves
yield self.host.memory.createProfile(guest_profile, password)
yield self.host.memory.startSession(password, guest_profile)
yield self.host.memory.setParam("JabberID", jid_.full(), "Connection",
profile_key=guest_profile)
yield self.host.memory.setParam("Password", password, "Connection",
profile_key=guest_profile)
name = kwargs.pop(u'name', None)
if name is not None:
extra[u'name'] = name
try:
id_plugin = self.host.plugins[u'IDENTITY']
except KeyError:
pass
else:
yield self.host.connect(guest_profile, password)
guest_client = self.host.getClient(guest_profile)
yield id_plugin.setIdentity(guest_client, {u'nick': name})
yield self.host.disconnect(guest_profile)
## email
language = kwargs.pop(u'language', None)
if language is not None:
extra[u'language'] = language.strip()
if email is not None:
extra[u'email'] = email
data_format.iter2dict(KEY_EMAILS_EXTRA, extra)
url_template = kwargs.pop(u'url_template', '')
format_args = {
u'uuid': id_,
u'app_name': C.APP_NAME,
u'app_url': C.APP_URL}
if name is None:
format_args[u'name'] = email
else:
format_args[u'name'] = name
profile = kwargs.pop(u'profile', None)
if profile is None:
format_args[u'profile'] = u''
else:
format_args[u'profile'] = extra[u'profile'] = profile
host_name = kwargs.pop(u'host_name', None)
if host_name is None:
format_args[u'host_name'] = profile or _(u"somebody")
else:
format_args[u'host_name'] = extra[u'host_name'] = host_name
invite_url = url_template.format(**format_args)
format_args[u'url'] = invite_url
yield sat_email.sendEmail(
self.host,
[email] + emails_extra,
(kwargs.pop(u'message_subject', None) or DEFAULT_SUBJECT).format(
**format_args),
(kwargs.pop(u'message_body', None) or DEFAULT_BODY).format(**format_args),
)
## extra data saving
self.invitations[id_] = extra
if kwargs:
log.warning(_(u"Not all arguments have been consumed: {}").format(kwargs))
extra[KEY_ID] = id_
extra[KEY_JID] = jid_
defer.returnValue(extra)
def get(self, id_):
"""Retrieve invitation linked to uuid if it exists
@param id_(unicode): UUID linked to an invitation
@return (dict[unicode, unicode]): data associated to the invitation
@raise KeyError: there is not invitation with this id_
"""
return self.invitations[id_]
def _modify(self, id_, new_extra, replace):
return self.modify(id_, {unicode(k): unicode(v) for k,v in new_extra.iteritems()},
replace)
def modify(self, id_, new_extra, replace=False):
"""Modify invitation data
@param id_(unicode): UUID linked to an invitation
@param new_extra(dict[unicode, unicode]): data to update
empty values will be deleted if replace is True
@param replace(bool): if True replace the data
else update them
@raise KeyError: there is not invitation with this id_
"""
self.checkExtra(new_extra)
def gotCurrentData(current_data):
if replace:
new_data = new_extra
for k in EXTRA_RESERVED:
try:
new_data[k] = current_data[k]
except KeyError:
continue
else:
new_data = current_data
for k,v in new_extra.iteritems():
if k in EXTRA_RESERVED:
log.warning(_(u"Skipping reserved key {key}".format(k)))
continue
if v:
new_data[k] = v
else:
try:
del new_data[k]
except KeyError:
pass
self.invitations[id_] = new_data
d = self.invitations[id_]
d.addCallback(gotCurrentData)
return d
def _list(self, profile=C.PROF_KEY_NONE):
return self.list(profile)
@defer.inlineCallbacks
def list(self, profile=C.PROF_KEY_NONE):
"""List invitations
@param profile(unicode): return invitation linked to this profile only
C.PROF_KEY_NONE: don't filter invitations
@return list(unicode): invitations uids
"""
invitations = yield self.invitations.items()
if profile != C.PROF_KEY_NONE:
invitations = {id_:data for id_, data in invitations.iteritems()
if data.get(u'profile') == profile}
defer.returnValue(invitations)