view src/plugins/plugin_misc_groupblog.py @ 1389:eb907923dce9

quick_frontend, primivitus: revert changes made in rev 1386 (1f3513cfb246) concerning ChatText.nick: was mixing MUC occupant nick and roster contact nick/node
author souliane <souliane@mailoo.org>
date Wed, 25 Mar 2015 09:42:05 +0100
parents 8aa4dab01206
children 069ad98b360d
line wrap: on
line source

#!/usr/bin/python
# -*- coding: utf-8 -*-

# SAT plugin for microbloging with roster access
# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014 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
log = getLogger(__name__)
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from twisted.words.xish.domish import Element, generateElementsNamed
from sat.core import exceptions
from wokkel import disco, data_form, iwokkel
from wokkel import rsm as wokkel_rsm
from zope.interface import implements
from feed import date
import uuid
import urllib

try:
    from twisted.words.protocols.xmlstream import XMPPHandler
except ImportError:
    from wokkel.subprotocols import XMPPHandler

NS_PUBSUB = 'http://jabber.org/protocol/pubsub'
NS_GROUPBLOG = 'http://goffi.org/protocol/groupblog'
NS_NODE_PREFIX = 'urn:xmpp:groupblog:'
NS_COMMENT_PREFIX = 'urn:xmpp:comments:'
#NS_PUBSUB_EXP = 'http://goffi.org/protocol/pubsub' #for non official features
NS_PUBSUB_EXP = NS_PUBSUB  # XXX: we can't use custom namespace as Wokkel's PubSubService use official NS
NS_PUBSUB_ITEM_ACCESS = NS_PUBSUB_EXP + "#item-access"
NS_PUBSUB_CREATOR_JID_CHECK = NS_PUBSUB_EXP + "#creator-jid-check"
NS_PUBSUB_ITEM_CONFIG = NS_PUBSUB_EXP + "#item-config"
NS_PUBSUB_AUTO_CREATE = NS_PUBSUB + "#auto-create"
TYPE_COLLECTION = 'collection'
ACCESS_TYPE_MAP = { 'PUBLIC': 'open',
                    'GROUP': 'roster',
                    'JID': None, #JID is not yet managed
                  }

MAX_ITEMS = 5
MAX_COMMENTS = 5
DO_NOT_COUNT_COMMENTS = -1  # must be lower than 0

PLUGIN_INFO = {
    "name": "Group blogging throught collections",
    "import_name": "GROUPBLOG",
    "type": "MISC",
    "protocols": [],
    "dependencies": ["XEP-0277"],
    "recommendations": ["XEP-0059"],
    "main": "GroupBlog",
    "handler": "yes",
    "description": _("""Implementation of microblogging with roster access""")
}


class NoCompatiblePubSubServerFound(Exception):
    pass


class BadAccessTypeError(Exception):
    pass


class BadAccessListError(Exception):
    pass


class UnknownType(Exception):
    pass

class GroupBlog(object):
    """This class use a SàT PubSub Service to manage access on microblog"""

    def __init__(self, host):
        log.info(_("Group blog plugin initialization"))
        self.host = host

        host.bridge.addMethod("sendGroupBlog", ".plugin", in_sign='sassa{ss}s', out_sign='',
                              method=self.sendGroupBlog,
                              async=True)

        host.bridge.addMethod("deleteGroupBlog", ".plugin", in_sign='(sss)ss', out_sign='',
                              method=self.deleteGroupBlog,
                              async=True)

        host.bridge.addMethod("updateGroupBlog", ".plugin", in_sign='(sss)ssa{ss}s', out_sign='',
                              method=self.updateGroupBlog,
                              async=True)

        host.bridge.addMethod("sendGroupBlogComment", ".plugin", in_sign='ssa{ss}s', out_sign='',
                              method=self.sendGroupBlogComment,
                              async=True)

        host.bridge.addMethod("getGroupBlogs", ".plugin",
                              in_sign='sasa{ss}bs', out_sign='(aa{ss}a{ss})',
                              method=self.getGroupBlogs,
                              async=True)

        host.bridge.addMethod("getGroupBlogsWithComments", ".plugin",
                              in_sign='sasa{ss}is', out_sign='(a(a{ss}(aa{ss}a{ss}))a{ss})',
                              method=self.getGroupBlogsWithComments,
                              async=True)

        host.bridge.addMethod("getGroupBlogsAtom", ".plugin",
                              in_sign='sa{ss}s', out_sign='s',
                              method=self.getGroupBlogsAtom,
                              async=True)

        host.bridge.addMethod("getMassiveGroupBlogs", ".plugin",
                              in_sign='sasa{ss}s', out_sign='a{s(aa{ss}a{ss})}',
                              method=self._getMassiveGroupBlogs,
                              async=True)

        host.bridge.addMethod("getGroupBlogComments", ".plugin",
                              in_sign='ssa{ss}s', out_sign='(aa{ss}a{ss})',
                              method=self.getGroupBlogComments,
                              async=True)

        host.bridge.addMethod("subscribeGroupBlog", ".plugin", in_sign='ss', out_sign='',
                              method=self.subscribeGroupBlog,
                              async=True)

        host.bridge.addMethod("massiveSubscribeGroupBlogs", ".plugin", in_sign='sass', out_sign='',
                              method=self._massiveSubscribeGroupBlogs,
                              async=True)

        host.trigger.add("PubSubItemsReceived", self.pubSubItemsReceivedTrigger)

    def getHandler(self, profile):
        return GroupBlog_handler()

    @defer.inlineCallbacks
    def _initialise(self, profile_key):
        """Check that the data for this profile are initialised, and do it else
        @param profile_key: %(doc_profile)s"""
        profile = self.host.memory.getProfileName(profile_key)
        if not profile:
            raise exceptions.ProfileUnknownError

        client = self.host.getClient(profile)

        #we first check that we have a item-access pubsub server
        if not hasattr(client, "item_access_pubsub"):
            log.debug(_('Looking for item-access powered pubsub server'))
            #we don't have any pubsub server featuring item access yet
            item_access_pubsubs = yield self.host.findFeaturesSet((NS_PUBSUB_AUTO_CREATE, NS_PUBSUB_CREATOR_JID_CHECK), "pubsub", "service", profile_key=profile)
            # item_access_pubsubs = yield self.host.findFeaturesSet((NS_PUBSUB_ITEM_ACCESS, NS_PUBSUB_AUTO_CREATE, NS_PUBSUB_CREATOR_JID_CHECK), "pubsub", "service", profile_key=profile)
            try:
                client.item_access_pubsub = item_access_pubsubs.pop()
                log.info(_("item-access powered pubsub service found: [%s]") % client.item_access_pubsub.full())
            except KeyError:
                client.item_access_pubsub = None

        if not client.item_access_pubsub:
            log.error(_("No item-access powered pubsub server found, can't use group blog"))
            raise NoCompatiblePubSubServerFound

        defer.returnValue((profile, client))

    def pubSubItemsReceivedTrigger(self, event, profile):
        """"Trigger which catch groupblogs events"""

        if event.nodeIdentifier.startswith(NS_NODE_PREFIX):
            # Microblog
            publisher = jid.JID(event.nodeIdentifier[len(NS_NODE_PREFIX):])
            origin_host = publisher.host.split('.')
            event_host = event.sender.host.split('.')
            #FIXME: basic origin check, must be improved
            #TODO: automatic security test
            if (not (origin_host)
                    or len(event_host) < len(origin_host)
                    or event_host[-len(origin_host):] != origin_host):
                log.warning("Host incoherence between %s and %s (hack attempt ?)" % (unicode(event.sender),
                                                                                 unicode(publisher)))
                return False

            client = self.host.getClient(profile)

            def gbdataManagementMicroblog(gbdata):
                for gbdatum in gbdata:
                    self.host.bridge.personalEvent(publisher.full(), "MICROBLOG", gbdatum, profile)

            d = self._itemsConstruction(event.items, publisher, client)
            d.addCallback(gbdataManagementMicroblog)
            return False

        elif event.nodeIdentifier.startswith(NS_COMMENT_PREFIX):
            # Comment
            def gbdataManagementComments(gbdata):
                for gbdatum in gbdata:
                    publisher = None # FIXME: see below (_handleCommentsItems)
                    self.host.bridge.personalEvent(publisher.full() if publisher else gbdatum["author"], "MICROBLOG", gbdatum, profile)
            d = self._handleCommentsItems(event.items, event.sender, event.nodeIdentifier)
            d.addCallback(gbdataManagementComments)
            return False
        return True

    def _handleCommentsItems(self, items, service, node_identifier):
        """ Convert comments items to groupblog data, and send them as signals
        @param items: comments items
        @param service: jid of the PubSub service used
        @param node_identifier: comments node
        @return: deferred list of group blog data
        """
        d_list = []

        def cb(microblog_data):
            publisher = "" # FIXME: publisher attribute for item in SàT pubsub is not managed yet, so
                           #        publisher is not checked and can be easily spoofed. This need to be fixed
                           #        quickly.
            microblog_data["service"] = service.userhost()
            microblog_data["node"] = node_identifier
            microblog_data["verified_publisher"] = "true" if publisher else "false"
            return microblog_data

        for item in items:
            d_list.append(self.item2gbdata(item, "comment").addCallback(cb))
        return defer.DeferredList(d_list, consumeErrors=True).addCallback(lambda result: [value for (success, value) in result if success])

    def _parseAccessData(self, microblog_data, item):
        P = self.host.plugins["XEP-0060"]
        form_elts = [child for child in item.elements() if child.name == "x"]
        for form_elt in form_elts:
            form = data_form.Form.fromElement(form_elt)

            if (form.formNamespace == NS_PUBSUB_ITEM_CONFIG):
                access_model = form.get(P.OPT_ACCESS_MODEL, 'open')
                if access_model == "roster":
                    try:
                        microblog_data["groups"] = '\n'.join(form.fields[P.OPT_ROSTER_GROUPS_ALLOWED].values)
                    except KeyError:
                        log.warning("No group found for roster access-model")
                        microblog_data["groups"] = ''

                break

    @defer.inlineCallbacks
    def item2gbdata(self, item, _type="main_item"):
        """ Convert item to microblog data dictionary + add access data """
        microblog_data = yield self.host.plugins["XEP-0277"].item2mbdata(item)
        microblog_data["type"] = _type
        self._parseAccessData(microblog_data, item)
        defer.returnValue(microblog_data)

    def getNodeName(self, publisher):
        """Retrieve the name of publisher's node
        @param publisher: publisher's jid
        @return: node's name (string)"""
        return NS_NODE_PREFIX + publisher.userhost()

    def _publishMblog(self, service, client, access_type, access_list, message, extra):
        """Actually publish the message on the group blog
        @param service: jid of the item-access pubsub service
        @param client: SatXMPPClient of the publisher
        @param access_type: one of "PUBLIC", "GROUP", "JID"
        @param access_list: set of entities (empty list for all, groups or jids) allowed to see the item
        @param message: message to publish
        @param extra: dict which option name as key, which can be:
            - allow_comments: True to accept comments, False else (default: False)
            - rich: if present, contain rich text in currently selected syntax
        """
        node_name = self.getNodeName(client.jid)
        mblog_data = {'content': message}

        for attr in ['content_rich', 'title', 'title_rich']:
            if attr in extra and extra[attr]:
                mblog_data[attr] = extra[attr]
        P = self.host.plugins["XEP-0060"]
        access_model_value = ACCESS_TYPE_MAP[access_type]

        if extra.get('allow_comments', 'False').lower() == 'true':
            # XXX: use the item identifier? http://bugs.goffi.org/show_bug.cgi?id=63
            comments_node = self.__fillCommentsElement(mblog_data, None, node_name, service)
            _options = {P.OPT_ACCESS_MODEL: access_model_value,
                        P.OPT_PERSIST_ITEMS: 1,
                        P.OPT_MAX_ITEMS: -1,
                        P.OPT_DELIVER_PAYLOADS: 1,
                        P.OPT_SEND_ITEM_SUBSCRIBE: 1,
                        P.OPT_PUBLISH_MODEL: "subscribers",  # TODO: should be open if *both* node and item access_model are open (public node and item)
                       }
            if access_model_value == 'roster':
                _options[P.OPT_ROSTER_GROUPS_ALLOWED] = list(access_list)

            # FIXME: check comments node creation success, at the moment this is a potential security risk (if the node
            #        already exists, the creation will silently fail, but the comments link will stay the same, linking to a
            #        node owned by somebody else)
            self.host.plugins["XEP-0060"].createNode(service, comments_node, _options, profile_key=client.profile)

        def itemCreated(mblog_item):
            form = data_form.Form('submit', formNamespace=NS_PUBSUB_ITEM_CONFIG)

            if access_type == "PUBLIC":
                if access_list:
                    raise BadAccessListError("access_list must be empty for PUBLIC access")
                access = data_form.Field(None, P.OPT_ACCESS_MODEL, value=access_model_value)
                form.addField(access)
            elif access_type == "GROUP":
                access = data_form.Field(None, P.OPT_ACCESS_MODEL, value=access_model_value)
                allowed = data_form.Field(None, P.OPT_ROSTER_GROUPS_ALLOWED, values=access_list)
                form.addField(access)
                form.addField(allowed)
                mblog_item.addChild(form.toElement())
            elif access_type == "JID":
                raise NotImplementedError
            else:
                log.error(_("Unknown access_type"))
                raise BadAccessTypeError

            defer_blog = self.host.plugins["XEP-0060"].publish(service, node_name, items=[mblog_item], profile_key=client.profile)
            defer_blog.addErrback(self._mblogPublicationFailed)
            return defer_blog

        entry_d = self.host.plugins["XEP-0277"].data2entry(mblog_data, client.profile)
        entry_d.addCallback(itemCreated)
        return entry_d

    def __fillCommentsElement(self, mblog_data, entry_id, node_name, service_jid):
        """
        @param mblog_data: dict containing the microblog data
        @param entry_id: unique identifier of the entry
        @param node_name: the pubsub node name
        @param service_jid: the JID of the pubsub service
        @return: the comments node string
        """
        if entry_id is None:
            entry_id = unicode(uuid.uuid4())
        comments_node = "%s_%s__%s" % (NS_COMMENT_PREFIX, entry_id, node_name)
        mblog_data['comments'] = "xmpp:%(service)s?%(query)s" % {'service': service_jid.userhost(),
                                                                 'query': urllib.urlencode([('node', comments_node.encode('utf-8'))])}
        return comments_node

    def _mblogPublicationFailed(self, failure):
        #TODO
        return failure

    def sendGroupBlog(self, access_type, access_list, message, extra, profile_key=C.PROF_KEY_NONE):
        """Publish a microblog with given item access
        @param access_type: one of "PUBLIC", "GROUP", "JID"
        @param access_list: list of authorized entity (empty list for PUBLIC ACCESS,
                            list of groups or list of jids) for this item
        @param message: microblog
        @param extra: dict which option name as key, which can be:
            - allow_comments: True to accept comments, False else (default: False)
            - rich: if present, contain rich text in currently selected syntax
        @profile_key: %(doc_profile_key)s
        """

        def initialised(result):
            profile, client = result
            if access_type == "PUBLIC":
                if access_list:
                    raise Exception("Publishers list must be empty when getting microblogs for all contacts")
                return self._publishMblog(client.item_access_pubsub, client, "PUBLIC", [], message, extra)
            elif access_type == "GROUP":
                _groups = set(access_list).intersection(client.roster.getGroups())  # We only keep group which actually exist
                if not _groups:
                    raise BadAccessListError("No valid group")
                return self._publishMblog(client.item_access_pubsub, client, "GROUP", _groups, message, extra)
            elif access_type == "JID":
                raise NotImplementedError
            else:
                log.error(_("Unknown access type"))
                raise BadAccessTypeError

        return self._initialise(profile_key).addCallback(initialised)

    def deleteGroupBlog(self, pub_data, comments, profile_key=C.PROF_KEY_NONE):
        """Delete a microblog item from a node.
        @param pub_data: a tuple (service, node identifier, item identifier)
        @param comments: comments node identifier (for main item) or empty string
        @param profile_key: %(doc_profile_key)s
        """

        def initialised(result):
            profile, client = result
            service, node, item_id = pub_data
            service_jid = jid.JID(service) if service else client.item_access_pubsub
            if comments or not node:  # main item
                node = self.getNodeName(client.jid)
            if comments:
                # remove the associated comments node
                comments_service, comments_node = self.host.plugins["XEP-0277"].parseCommentUrl(comments)
                d = self.host.plugins["XEP-0060"].deleteNode(comments_service, comments_node, profile_key=profile)
                d.addErrback(lambda failure: log.error("Deletion of node %s failed: %s" % (comments_node, failure.getErrorMessage())))
            # remove the item itself
            d = self.host.plugins["XEP-0060"].retractItems(service_jid, node, [item_id], profile_key=profile)
            d.addErrback(lambda failure: log.error("Deletion of item %s from %s failed: %s" % (item_id, node, failure.getErrorMessage())))
            return d

        def notify(d):
            # TODO: this works only on the same host, and notifications for item deletion should be
            # implemented according to http://xmpp.org/extensions/xep-0060.html#publisher-delete-success-notify
            # instead. The notification mechanism implemented in sat_pubsub and wokkel have apriori
            # a problem with retrieving the subscriptions, or something else.
            service, node, item_id = pub_data
            publisher = self.host.getJidNStream(profile_key)[0]
            profile = self.host.memory.getProfileName(profile_key)
            gbdatum = {'id': item_id, 'type': 'main_item' if (comments or not node) else 'comment'}
            self.host.bridge.personalEvent(publisher.full(), "MICROBLOG_DELETE", gbdatum, profile)
            return d

        return self._initialise(profile_key).addCallback(initialised).addCallback(notify)

    def updateGroupBlog(self, pub_data, comments, message, extra, profile_key=C.PROF_KEY_NONE):
        """Modify a microblog node
        @param pub_data: a tuple (service, node identifier, item identifier)
        @param comments: comments node identifier (for main item) or empty string
        @param message: new message
        @param extra: dict which option name as key, which can be:
            - allow_comments: True to accept an other level of comments, False else (default: False)
            - rich: if present, contain rich text in currently selected syntax
        @param profile_key: %(doc_profile)
        """

        def initialised(result):
            profile, client = result
            mblog_data = {'content': message}
            for attr in ['content_rich', 'title', 'title_rich']:
                if attr in extra and extra[attr]:
                    mblog_data[attr] = extra[attr]
            service, node, item_id = pub_data
            service_jid = jid.JID(service) if service else client.item_access_pubsub
            if comments or not node:  # main item
                node = self.getNodeName(client.jid)
            mblog_data['id'] = unicode(item_id)
            if 'published' in extra:
                mblog_data['published'] = extra['published']
            if extra.get('allow_comments', 'False').lower() == 'true':
                comments_service, comments_node = self.host.plugins["XEP-0277"].parseCommentUrl(comments)
                # we could use comments_node directly but it's safer to rebuild it
                # XXX: use the item identifier? http://bugs.goffi.org/show_bug.cgi?id=63
                entry_id = comments_node.split('_')[1].split('__')[0]
                self.__fillCommentsElement(mblog_data, entry_id, node, service_jid)
            entry_d = self.host.plugins["XEP-0277"].data2entry(mblog_data, profile)
            entry_d.addCallback(lambda mblog_item: self.host.plugins["XEP-0060"].publish(service_jid, node, items=[mblog_item], profile_key=profile))
            entry_d.addErrback(lambda failure: log.error("Modification of %s failed: %s" % (pub_data, failure.getErrorMessage())))
            return entry_d

        return self._initialise(profile_key).addCallback(initialised)

    def sendGroupBlogComment(self, node_url, message, extra, profile_key=C.PROF_KEY_NONE):
        """Publish a comment in the given node
        @param node url: link to the comments node as specified in XEP-0277 and given in microblog data's comments key
        @param message: comment
        @param extra: dict which option name as key, which can be:
            - allow_comments: True to accept an other level of comments, False else (default: False)
            - rich: if present, contain rich text in currently selected syntax
        @profile_key: %(doc_profile)s
        """
        def initialised(result):
            profile, client = result
            service, node = self.host.plugins["XEP-0277"].parseCommentUrl(node_url)
            mblog_data = {'content': message}
            for attr in ['content_rich', 'title', 'title_rich']:
                if attr in extra and extra[attr]:
                    mblog_data[attr] = extra[attr]
            if 'allow_comments' in extra:
                raise NotImplementedError # TODO
            entry_d = self.host.plugins["XEP-0277"].data2entry(mblog_data, profile)
            entry_d.addCallback(lambda mblog_item: self.host.plugins["XEP-0060"].publish(service, node, items=[mblog_item], profile_key=profile))
            return entry_d

        return self._initialise(profile_key).addCallback(initialised)

    def _itemsConstruction(self, items, pub_jid, client):
        """ Transforms items to group blog data and manage comments node

        @param items: iterable of items
        @param pub_jid: jid of the publisher or None to use items data
        @param client: SatXMPPClient instance
        @return: deferred which fire list of group blog data """
        # TODO: use items data when pub_jid is None
        d_list = []

        @defer.inlineCallbacks
        def cb(gbdata):
            try:
                gbdata['service'] = client.item_access_pubsub.full()
            except AttributeError:
                log.warning(_("Pubsub service is unknown for blog entry %s") % gbdata['id'])
            # every comments node must be subscribed, except if we are the publisher (we are already subscribed in this case)
            if "comments_node" in gbdata and pub_jid.userhostJID() != client.jid.userhostJID():
                try:
                    service = jid.JID(gbdata["comments_service"])
                    node = gbdata["comments_node"]
                except KeyError:
                    log.error(_("Missing key for blog comment %s") % gbdata['id'])
                    defer.returnValue(gbdata)
                # TODO: see if it is really needed to check for not subscribing twice to the node
                # It previously worked without this check, but the pubsub service logs were polluted
                # or, if in debug mode, it made sat-pubsub very difficult to debug.
                subscribed_nodes = yield self.host.plugins['XEP-0060'].listSubscribedNodes(service, profile=client.profile)
                if node not in subscribed_nodes:  # avoid sat-pubsub "SubscriptionExists" error
                    self.host.plugins["XEP-0060"].subscribe(service, node, profile_key=client.profile)
            defer.returnValue(gbdata)

        for item in items:
            d_list.append(self.item2gbdata(item).addCallback(cb))
        return defer.DeferredList(d_list, consumeErrors=True).addCallback(lambda result: [value for (success, value) in result if success])

    def _getOrCountComments(self, items, max=0, profile_key=C.PROF_KEY_NONE):
        """Get and/or count the comments of the given items.

        @param items (list): items to consider.
        @param max (int): maximum number of comments to get, if 0 only count
            them. The count is set to the item data of key "comments_count".
        @param profile_key (str): %(doc_profile_key)s
        @return: a deferred list of:
            - if max == 0: microblog data
            - else: couple (dict, (list[dict], dict)) containing:
                - microblog data (main item)
                - couple (comments data, RSM response data for the comments)
        """
        def comments_cb(comments_data, entry):
            entry['comments_count'] = comments_data[1]['count']
            return (entry, comments_data) if max > 0 else entry

        assert(max >= 0)
        d_list = []
        for entry in items:
            if entry.get('comments', False):
                comments_rsm = {'max': max}
                d = self.getGroupBlogComments(entry['comments_service'], entry['comments_node'], rsm=comments_rsm, profile_key=profile_key)
                d.addCallback(comments_cb, entry)
                d_list.append(d)
            else:
                if max > 0:
                    d_list.append(defer.succeed((entry, ([], {}))))
                else:
                    d_list.append(defer.succeed(entry))
        deferred_list = defer.DeferredList(d_list)
        deferred_list.addCallback(lambda result: [value for (success, value) in result if success])
        return deferred_list

    def __getGroupBlogs(self, pub_jid_s, item_ids=None, rsm=None, max_comments=0, profile_key=C.PROF_KEY_NONE):
        """Retrieve previously published items from a publish subscribe node.

        @param pub_jid_s: jid of the publisher
        @param item_ids: list of microblogs items IDs
        @param rsm (dict): RSM request data
        @param max_comments (int): maximum number of comments to retrieve
        @param profile_key (str): %(doc_profile_key)s
        @return: a deferred couple (list, dict) containing:
            - list of:
                - if max_comments == 0: microblog data
                - else: couple (dict, (list[dict], dict)) containing:
                    - microblog data (main item)
                    - couple (comments data, RSM response data for the comments)
            - RSM response data
        """
        pub_jid = jid.JID(pub_jid_s)

        def cb(items, client):
            d = self._itemsConstruction(items, pub_jid, client)
            if max_comments == DO_NOT_COUNT_COMMENTS:
                return d
            return d.addCallback(self._getOrCountComments, max_comments, profile_key)

        return DeferredItems(self, cb, None, profile_key).get(self.getNodeName(pub_jid), item_ids, rsm=rsm)

    def getGroupBlogs(self, pub_jid_s, item_ids=None, rsm=None, count_comments=True, profile_key=C.PROF_KEY_NONE):
        """Get the published microblogs of the specified IDs. If item_ids is
        None, the result would be the same than calling getGroupBlogs
        with the default value for the attribute max_items.

        @param pub_jid_s: jid of the publisher
        @param item_ids: list of microblogs items IDs
        @param rsm (dict): RSM request data
        @param count_comments (bool): also count the comments if True
        @param profile_key (str): %(doc_profile_key)s
        @return: a deferred couple (list, dict) containing:
            - list of microblog data
            - RSM response data
        """
        max_comments = 0 if count_comments else DO_NOT_COUNT_COMMENTS
        return self.__getGroupBlogs(pub_jid_s, item_ids=item_ids, rsm=rsm, max_comments=max_comments, profile_key=profile_key)

    def getGroupBlogsWithComments(self, pub_jid_s, item_ids=None, rsm=None, max_comments=None, profile_key=C.PROF_KEY_NONE):
        """Get the published microblogs of the specified IDs and their comments. If
        item_ids is None, returns the last published microblogs and their comments.

        @param pub_jid_s: jid of the publisher
        @param item_ids: list of microblogs items IDs
        @param rsm (dict): RSM request data
        @param max_comments (int): maximum number of comments to retrieve
        @param profile_key (str): %(doc_profile_key)s
        @return: a deferred couple (list, dict) containing:
            - list of couple (dict, (list[dict], dict)) containing:
                - microblog data (main item)
                - couple (comments data, RSM response data for the comments)
            - RSM response data
        """
        if max_comments is None:
            max_comments = MAX_COMMENTS
        assert(max_comments > 0)  # otherwise the return signature is not the same
        return self.__getGroupBlogs(pub_jid_s, item_ids=item_ids, rsm=rsm, max_comments=max_comments, profile_key=profile_key)

    def getGroupBlogsAtom(self, pub_jid_s, rsm=None, profile_key=C.PROF_KEY_NONE):
        """Get the atom feed of the last published microblogs
        @param pub_jid: jid of the publisher
        @param profile_key: profile key
        @return: a deferred unicode (atom XML feed)
        """
        pub_jid = jid.JID(pub_jid_s)

        def removeAllURIs(element):
            """Recursively remove the URIs of the element and its children.
            Without that, the entry would still be valid but not displayed
            by Firefox nor Thunderbird (and probably more readers)"""
            element.uri = element.defaultUri = None
            for child in element.children:
                if isinstance(child, Element):
                    removeAllURIs(child)

        def items2feed(items, pub_jid, client):
            feed = """<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <title>%(user)s's blogposts</title>
    <link href="%(feed)s" rel="self" />
    <link href="%(blog)s" />
    <id>%(id)s</id>
    <updated>%(date)s</updated>\n""" % {'user': pub_jid.user,
                                        'feed': 'http://%s/blog/%s/atom.xml' % (client.jid.host, pub_jid.user),
                                        'blog': 'http://%s/blog/%s' % (client.jid.host, pub_jid.user),
                                        'id': self.getNodeName(pub_jid),
                                        'date': date.rfc3339.timestamp_from_tf(date.rfc3339.tf_utc())}
            for item in items:
                entry = item.firstChildElement()
                removeAllURIs(entry)
                feed += "    " + entry.toXml() + "\n"
            return feed + "</feed>"

        def cb(items, client):
            return items2feed(items, pub_jid, client)

        d = DeferredItems(self, cb, lambda dummy: [''], profile_key).get(self.getNodeName(pub_jid), rsm=rsm)
        return d.addCallback(lambda res: res[0])

    def getGroupBlogComments(self, service_s, node, rsm=None, profile_key=C.PROF_KEY_NONE):
        """Get all comments of given node
        @param service_s: service hosting the node
        @param node: comments node
        @param profile_key: profile key
        @return: a deferred couple (list, dict) containing:
            - list of microblog data
            - RSM response data
        """
        service = jid.JID(service_s)

        def cb(items, client):
            return self._handleCommentsItems(items, service, node)

        return DeferredItems(self, cb, None, profile_key).get(node, rsm=rsm)

    def _getMassiveGroupBlogs(self, publishers_type, publishers, rsm=None, profile_key=C.PROF_KEY_NONE):
        if publishers_type == 'JID':
            publishers_jids = [jid.JID(publisher) for publisher in publishers]
        else:
            publishers_jids = publishers
        return self.getMassiveGroupBlogs(publishers_type, publishers_jids, rsm, profile_key)

    def _getPublishersJIDs(self, publishers_type, publishers, client):
        #TODO: custom exception
        if publishers_type not in ["GROUP", "JID", "ALL"]:
            raise Exception("Bad call, unknown publishers_type")
        if publishers_type == "ALL" and publishers:
            raise Exception("Publishers list must be empty when getting microblogs for all contacts")

        if publishers_type == "ALL":
            contacts = client.roster.getItems()
            jids = [contact.jid.userhostJID() for contact in contacts]
        elif publishers_type == "GROUP":
            jids = []
            for _group in publishers:
                jids.extend(client.roster.getJidsFromGroup(_group))
        elif publishers_type == 'JID':
            jids = publishers
        else:
            raise UnknownType
        return jids

    def getMassiveGroupBlogs(self, publishers_type, publishers, rsm=None, profile_key=C.PROF_KEY_NONE):
        """Get the last published microblogs for a list of groups or jids
        @param publishers_type (str): type of the list of publishers (one of "GROUP" or "JID" or "ALL")
        @param publishers (list): list of publishers, according to publishers_type (list of groups or list of jids)
        @param rsm (dict): RSM request data, common to all publishers
        @param profile_key: profile key
        @return: a deferred dict with:
            - key: publisher (unicode)
            - value: couple (list[dict], dict) with:
                - the microblogs data
                - RSM response data
        """
        def cb(items, publisher, client):
            d = self._itemsConstruction(items, publisher, client)
            return d.addCallback(self._getOrCountComments, False, profile_key)

        #TODO: we need to use the server corresponding to the host of the jid
        return DeferredItemsFromMany(self, cb, profile_key).get(publishers_type, publishers, rsm=rsm)

    def subscribeGroupBlog(self, pub_jid, profile_key=C.PROF_KEY_NONE):
        def initialised(result):
            profile, client = result
            d = self.host.plugins["XEP-0060"].subscribe(client.item_access_pubsub, self.getNodeName(jid.JID(pub_jid)),
                                                        profile_key=profile_key)
            return d

        #TODO: we need to use the server corresponding the the host of the jid
        return self._initialise(profile_key).addCallback(initialised)

    def _massiveSubscribeGroupBlogs(self, publishers_type, publishers, profile_key=C.PROF_KEY_NONE):
        if publishers_type == 'JID':
            publishers_jids = [jid.JID(publisher) for publisher in publishers]
        else:
            publishers_jids = publishers
        return self.massiveSubscribeGroupBlogs(publishers_type, publishers_jids, profile_key)

    @defer.inlineCallbacks
    def massiveSubscribeGroupBlogs(self, publishers_type, publishers, profile_key=C.PROF_KEY_NONE):
        """Subscribe microblogs for a list of groups or jids
        @param publishers_type: type of the list of publishers (one of "GROUP" or "JID" or "ALL")
        @param publishers: list of publishers, according to "publishers_type" (list of groups or list of jids)
        @param profile_key: profile key
        """
        profile, client = yield self._initialise(profile_key)
        #TODO: we need to use the server corresponding the the host of the jid

        jids = self._getPublishersJIDs(publishers_type, publishers, client)
        node_ids = [self.getNodeName(publisher) for publisher in jids]
        d_list = yield self.host.plugins["XEP-0060"].subscribeToMany(client.item_access_pubsub, node_ids, profile_key=profile_key)
        result = yield defer.DeferredList(d_list, consumeErrors=False)
        defer.returnValue(None)

    def deleteAllGroupBlogsAndComments(self, profile_key=C.PROF_KEY_NONE):
        """Delete absolutely all the microblog data that the user has posted"""
        calls = [self.deleteAllGroupBlogs(profile_key), self.deleteAllGroupBlogsComments(profile_key)]
        return defer.DeferredList(calls)

    def deleteAllGroupBlogs(self, profile_key=C.PROF_KEY_NONE):
        """Delete all the main items that the user has posted and their comments.
        """
        def initialised(result):
            profile, client = result
            service = client.item_access_pubsub
            jid_ = client.jid
            main_node = self.getNodeName(jid_)

            def cb(nodes):
                d_list = []
                for node in [node for node in nodes if node.endswith(main_node)]:
                    d = self.host.plugins["XEP-0060"].deleteNode(service, node, profile_key=profile)
                    d.addErrback(lambda failure: log.error(_("Deletion of node %(node)s failed: %(message)s") %
                                                           {'node': node, 'message': failure.getErrorMessage()}))
                    d_list.append(d)
                return defer.DeferredList(d_list)

            d = self.host.plugins["XEP-0060"].listNodes(service, profile=profile)
            d.addCallback(cb)
            d.addCallback(lambda dummy: log.info(_("All microblog's main items from %s have been deleted!") % jid_.userhost()))
            return d

        return self._initialise(profile_key).addCallback(initialised)

    def deleteAllGroupBlogsComments(self, profile_key=C.PROF_KEY_NONE):
        """Delete all the comments that the user posted on other's main items.
        We avoid the conversions from item to microblog data as we only need
        to retrieve some attributes, no need to convert text syntax...
        """
        def initialised(result):
            """Get all the main items from our contact list
            @return: a DeferredList
            """
            profile, client = result
            service = client.item_access_pubsub
            jids = [contact.jid.userhostJID() for contact in client.roster.getItems()]
            blogs = []
            for jid_ in jids:
                if jid_ == client.jid.userhostJID():
                    continue  # do not remove the comments on our own node
                main_node = self.getNodeName(jid_)
                d = self.host.plugins["XEP-0060"].getItems(service, main_node, profile_key=profile)
                d.addCallback(lambda res: getComments(res[0], client))
                d.addErrback(lambda failure, main_node: log.error(_("Retrieval of items for node %(node)s failed: %(message)s") %
                                                              {'node': main_node, 'message': failure.getErrorMessage()}), main_node)
                blogs.append(d)

            return defer.DeferredList(blogs)

        def getComments(items, client):
            """Get all the comments for a list of items
            @param items: a list of main items for one user
            @param client: the client of the user
            @return: a DeferredList
            """
            comments = []
            for item in items:
                try:
                    entry = generateElementsNamed(item.elements(), 'entry').next()
                    link = generateElementsNamed(entry.elements(), 'link').next()
                except StopIteration:
                    continue
                href = link.getAttribute('href')
                service, node = self.host.plugins['XEP-0277'].parseCommentUrl(href)
                d = self.host.plugins["XEP-0060"].getItems(service, node, profile_key=profile_key)
                d.addCallback(lambda items: (service, node, items[0]))
                d.addErrback(lambda failure, node: log.error(_("Retrieval of comments for node %(node)s failed: %(message)s") %
                                                         {'node': node, 'message': failure.getErrorMessage()}), node)
                comments.append(d)
            dlist = defer.DeferredList(comments)
            dlist.addCallback(deleteComments, client)
            return dlist

        def deleteComments(result, client):
            """Delete all the comments of the user that are found in result
            @param result: a list of couple (success, value) with success a
            boolean and value a tuple (service as JID, node_id, comment_items)
            @param client: the client of the user
            @return: a DeferredList with the deletions result
            """
            user_jid_s = client.jid.userhost()
            for (success, value) in result:
                if not success:
                    continue
                service, node_id, comment_items = value
                item_ids = []
                for comment_item in comment_items:  # for all the comments on one post
                    try:
                        entry = generateElementsNamed(comment_item.elements(), 'entry').next()
                        author = generateElementsNamed(entry.elements(), 'author').next()
                        name = generateElementsNamed(author.elements(), 'name').next()
                    except StopIteration:
                        continue
                    if name.children[0] == user_jid_s:
                        item_ids.append(comment_item.getAttribute('id'))
                deletions = []
                if item_ids:  # remove the comments of the user on the given post
                    d = self.host.plugins['XEP-0060'].retractItems(service, node_id, item_ids, profile_key=profile_key)
                    d.addCallback(lambda dummy, node_id: log.debug(_('Comments of user %(user)s in node %(node)s have been retracted') %
                                                      {'user': user_jid_s, 'node': node_id}), node_id)
                    d.addErrback(lambda failure, node_id: log.error(_("Retraction of comments from %(user)s in node %(node)s failed: %(message)s") %
                                                       {'user': user_jid_s, 'node': node_id, 'message': failure.getErrorMessage()}), node_id)
                    deletions.append(d)
            return defer.DeferredList(deletions)

        return self._initialise(profile_key).addCallback(initialised)


class DeferredItems():
    """Helper class to retrieve items using XEP-0060"""

    def __init__(self, parent, cb, eb=None, profile_key=C.PROF_KEY_NONE):
        """
        @param parent (GroupBlog): GroupBlog instance
        @param cb (callable): callback method to be applied on items
        @param eb (callable): errback method to be applied on items
        @param profile_key (str): %(doc_profile_key)s
        """
        self.parent = parent
        self.cb = cb
        self.eb = (lambda dummy: ([], {})) if eb is None else eb
        self.profile_key = profile_key

    def get(self, node, item_ids=None, sub_id=None, rsm=None):
        """
        @param node (str): node identifier.
        @param item_ids (list[str]): list of items identifiers.
        @param sub_id (str): optional subscription identifier.
        @param rsm (dict): RSM request data
        @return: a deferred couple (list, dict) containing:
            - list of microblog data
            - RSM response data
        """
        if rsm is None:
            rsm = {'max': (len(item_ids) if item_ids else MAX_ITEMS)}

        def initialised(result):
            profile, client = result
            rsm_ = wokkel_rsm.RSMRequest(**rsm)
            d = self.parent.host.plugins["XEP-0060"].getItems(client.item_access_pubsub,
                                                              node, rsm_.max,
                                                              item_ids, sub_id, rsm_,
                                                              profile_key=profile)

            def cb(result):
                d = defer.maybeDeferred(self.cb, result[0], client)
                return d.addCallback(lambda items: (items, result[1]))

            d.addCallbacks(cb, self.eb)
            return d

        #TODO: we need to use the server corresponding to the host of the jid
        return self.parent._initialise(self.profile_key).addCallback(initialised)


class DeferredItemsFromMany():
    def __init__(self, parent, cb, profile_key=C.PROF_KEY_NONE):
        """
        @param parent (GroupBlog): GroupBlog instance
        @param cb (callable): callback method to be applied on items
        @param profile_key (str): %(doc_profile_key)s
        """
        self.parent = parent
        self.cb = cb
        self.profile_key = profile_key

    def __buildData(self, publishers_type, publishers, client):
        jids = self.parent._getPublishersJIDs(publishers_type, publishers, client)
        return {publisher: self.parent.getNodeName(publisher) for publisher in jids}

    def get(self, publishers_type, publishers, sub_id=None, rsm=None):
        """
        @param publishers_type (str): type of the list of publishers (one of "GROUP" or "JID" or "ALL")
        @param publishers (list): list of publishers, according to publishers_type (list of groups or list of jids)
        @param sub_id (str): optional subscription identifier.
        @param rsm (dict): RSM request data
        @return: a deferred dict with:
            - key: publisher (unicode)
            - value: couple (list[dict], dict) with:
                - the microblogs data
                - RSM response data
        """
        if rsm is None:
            rsm = {'max': MAX_ITEMS}

        def initialised(result):
            profile, client = result

            data = self.__buildData(publishers_type, publishers, client)
            rsm_ = wokkel_rsm.RSMRequest(**rsm)
            d = self.parent.host.plugins["XEP-0060"].getItemsFromMany(client.item_access_pubsub,
                                                                      data, rsm_.max, sub_id,
                                                                      rsm_, profile_key=profile)

            def cb(publisher):
                def callback(result):
                    d = defer.maybeDeferred(self.cb, result[0], publisher, client)
                    d.addCallback(lambda items: (publisher.full(), (items, result[1])))
                    return d
                return callback

            def cb_list(result):
                return {value[0]: value[1] for success, value in result if success}

            def main_cb(result):
                d_list = []
                for publisher, d_items in result.items():
                    # XXX: trick needed as publisher is a loop variable
                    d_list.append(d_items.addCallback(cb(publisher)))
                return defer.DeferredList(d_list, consumeErrors=False).addCallback(cb_list)

            d.addCallback(main_cb)
            return d

        #TODO: we need to use the server corresponding to the host of the jid
        return self.parent._initialise(self.profile_key).addCallback(initialised)


class GroupBlog_handler(XMPPHandler):
    implements(iwokkel.IDisco)

    def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
        return [disco.DiscoFeature(NS_GROUPBLOG)]

    def getDiscoItems(self, requestor, target, nodeIdentifier=''):
        return []