view src/plugins/plugin_xep_0085.py @ 1259:633fcd13a7dc

plugin pubsub: fixed a bug introducted in revision 318eab3f93f8: getDiscoItems handler method which is called on disco items request, was calling getDiscoItems from host, which do a request itself, resulting in an infinite items request loop.
author Goffi <goffi@goffi.org>
date Fri, 21 Nov 2014 16:35:40 +0100
parents c13a46207410
children 60dfa2f5d61f
line wrap: on
line source

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

# SAT plugin for Chat State Notifications Protocol (xep-0085)
# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014 Adrien Cossa (souliane@mailoo.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 import exceptions
from sat.core.log import getLogger
log = getLogger(__name__)
from wokkel import disco, iwokkel
from zope.interface import implements
from twisted.words.protocols.jabber.jid import JID
try:
    from twisted.words.protocols.xmlstream import XMPPHandler
except ImportError:
    from wokkel.subprotocols import XMPPHandler
from threading import Timer
from twisted.words.xish import domish

NS_XMPP_CLIENT = "jabber:client"
NS_CHAT_STATES = "http://jabber.org/protocol/chatstates"
CHAT_STATES = ["active", "inactive", "gone", "composing", "paused"]
MESSAGE_TYPES = ["chat", "groupchat"]
PARAM_KEY = "Notifications"
PARAM_NAME = "Enable chat state notifications"
ENTITY_KEY = PARAM_KEY + "_" + PARAM_NAME

PLUGIN_INFO = {
    "name": "Chat State Notifications Protocol Plugin",
    "import_name": "XEP-0085",
    "type": "XEP",
    "protocols": ["XEP-0085"],
    "dependencies": [],
    "main": "XEP_0085",
    "handler": "yes",
    "description": _("""Implementation of Chat State Notifications Protocol""")
}


# Describe the internal transitions that are triggered
# by a timer. Beside that, external transitions can be
# runned to target the states "active" or "composing".
# Delay is specified here in seconds.
TRANSITIONS = {
    "active": {"next_state": "inactive", "delay": 120},
    "inactive": {"next_state": "gone", "delay": 480},
    "gone": {"next_state": "", "delay": 0},
    "composing": {"next_state": "paused", "delay": 30},
    "paused": {"next_state": "inactive", "delay": 450}
}


class UnknownChatStateException(Exception):
    """
    This error is raised when an unknown chat state is used.
    """
    pass


class XEP_0085(object):
    """
    Implementation for XEP 0085
    """
    params = """
    <params>
    <individual>
    <category name="%(category_name)s" label="%(category_label)s">
        <param name="%(param_name)s" label="%(param_label)s" value="true" type="bool" security="0"/>
     </category>
    </individual>
    </params>
    """ % {
        'category_name': PARAM_KEY,
        'category_label': _(PARAM_KEY),
        'param_name': PARAM_NAME,
        'param_label': _('Enable chat state notifications')
    }

    def __init__(self, host):
        log.info(_("Chat State Notifications plugin initialization"))
        self.host = host
        self.map = {}

        # parameter value is retrieved before each use
        host.memory.updateParams(self.params)

        # triggers from core
        host.trigger.add("MessageReceived", self.messageReceivedTrigger)
        host.trigger.add("sendMessage", self.sendMessageTrigger)
        host.trigger.add("paramUpdateTrigger", self.paramUpdateTrigger)

        # args: to_s (jid as string), profile
        host.bridge.addMethod("chatStateComposing", ".plugin", in_sign='ss',
                              out_sign='', method=self.chatStateComposing)

        # args: from (jid as string), state in CHAT_STATES, profile
        host.bridge.addSignal("chatStateReceived", ".plugin", signature='sss')

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

    def profileDisconnected(self, profile):
        """Eventually send a 'gone' state to all one2one contacts."""
        if profile not in self.map:
            return
        for to_jid in self.map[profile]:
            # FIXME: the "unavailable" presence stanza is received by to_jid
            # before the chat state, so it will be ignored... find a way to
            # actually defer the disconnection
            self.map[profile][to_jid]._onEvent('gone')
        del self.map[profile]

    def updateEntityData(self, entity_jid, value, profile):
        """
        Update the entity data of the given profile for one or all contacts.
        Reset the chat state(s) display if the notification has been disabled.
        @param entity_jid: contact's JID, or '@ALL@' to update all contacts.
        @param value: True, False or C.PROF_KEY_NONE to delete the entity data
        @param profile: current profile
        """
        self.host.memory.updateEntityData(entity_jid, ENTITY_KEY, value, profile)
        if not value or value == C.PROF_KEY_NONE:
            # disable chat state for this or these contact(s)
            self.host.bridge.chatStateReceived(unicode(entity_jid), "", profile)

    def paramUpdateTrigger(self, name, value, category, type_, profile):
        """
        Reset all the existing chat state entity data associated with this profile after a parameter modification.
        @param name: parameter name
        @param value: "true" to activate the notifications, or any other value to delete it
        @param category: parameter category
        @param type_: parameter type
        """
        if (category, name) == (PARAM_KEY, PARAM_NAME):
            self.updateEntityData("@ALL@", True if value == "true" else C.PROF_KEY_NONE, profile)
            return False
        return True

    def messageReceivedTrigger(self, message, post_treat, profile):
        """
        Update the entity cache when we receive a message with body.
        Check for a chat state in the message and signal frontends.
        """
        if not self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile):
            return True

        from_jid = JID(message.getAttribute("from"))
        if self.__isMUC(from_jid, profile):
            from_jid = from_jid.userhostJID()
        else:  # update entity data for one2one chat
            assert(from_jid.resource)
            try:
                domish.generateElementsNamed(message.elements(), name="body").next()
                try:
                    domish.generateElementsNamed(message.elements(), name="active").next()
                    # contact enabled Chat State Notifications
                    self.updateEntityData(from_jid, True, profile)
                except StopIteration:
                    if message.getAttribute('type') == 'chat':
                        # contact didn't enable Chat State Notifications
                        self.updateEntityData(from_jid, False, profile)
                        return True
            except StopIteration:
                pass

        # send our next "composing" states to any MUC and to the contacts who enabled the feature
        self.__chatStateInit(from_jid, message.getAttribute("type"), profile)

        state_list = [child.name for child in message.elements() if
                      message.getAttribute("type") in MESSAGE_TYPES
                      and child.name in CHAT_STATES
                      and child.defaultUri == NS_CHAT_STATES]
        for state in state_list:
            # there must be only one state according to the XEP
            if state != 'gone' or message.getAttribute('type') != 'groupchat':
                self.host.bridge.chatStateReceived(message.getAttribute("from"), state, profile)
            break
        return True

    def sendMessageTrigger(self, mess_data, pre_xml_treatments, post_xml_treatments, profile):
        """
        Eventually add the chat state to the message and initiate
        the state machine when sending an "active" state.
        """
        def treatment(mess_data):
            message = mess_data['xml']
            to_jid = JID(message.getAttribute("to"))
            if not self.__checkActivation(to_jid, forceEntityData=True, profile=profile):
                return mess_data
            try:
                # message with a body always mean active state
                domish.generateElementsNamed(message.elements(), name="body").next()
                message.addElement('active', NS_CHAT_STATES)
                # launch the chat state machine (init the timer)
                if self.__isMUC(to_jid, profile):
                    to_jid = to_jid.userhostJID()
                self.__chatStateActive(to_jid, mess_data["type"], profile)
            except StopIteration:
                if "chat_state" in mess_data["extra"]:
                    state = mess_data["extra"].pop("chat_state")
                    assert(state in CHAT_STATES)
                    message.addElement(state, NS_CHAT_STATES)
            return mess_data

        post_xml_treatments.addCallback(treatment)
        return True

    def __isMUC(self, to_jid, profile):
        """Tell if that JID is a MUC or not

        @param to_jid (JID): full or bare JID to check
        @param profile (str): %(doc_profile)s
        @return: bool
        """
        try:
            type_ = self.host.memory.getEntityDatum(to_jid.userhostJID(), 'type', profile)
            if type_ == 'chatroom':
                return True
        except (exceptions.UnknownEntityError, KeyError):
            pass
        return False

    def __checkActivation(self, to_jid, forceEntityData, profile):
        """
        @param to_jid: the contact's full JID (or bare if you know it's a MUC)
        @param forceEntityData: if set to True, a non-existing
        entity data will be considered to be True (and initialized)
        @param: current profile
        @return: True if the notifications should be sent to this JID.
        """
        # check if the parameter is active
        if not self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile):
            return False
        # check if notifications should be sent to this contact
        if self.__isMUC(to_jid, profile):
            return True
        assert(to_jid.resource or not self.host.memory.isContactConnected(to_jid, profile))
        try:
            return self.host.memory.getEntityDatum(to_jid, ENTITY_KEY, profile)
        except (exceptions.UnknownEntityError, KeyError):
            if forceEntityData:
                # enable it for the first time
                self.updateEntityData(to_jid, True, profile)
                return True
        # wait for the first message before sending states
        return False

    def __chatStateInit(self, to_jid, mess_type, profile):
        """
        Data initialization for the chat state machine.

        @param to_jid (JID): full JID for one2one, bare JID for MUC
        @param mess_type (str): "one2one" or "groupchat"
        @param profile (str): %(doc_profile)s
        """
        if mess_type is None:
            return
        profile_map = self.map.setdefault(profile, {})
        if to_jid not in profile_map:
            machine = ChatStateMachine(self.host, to_jid,
                                       mess_type, profile)
            self.map[profile][to_jid] = machine

    def __chatStateActive(self, to_jid, mess_type, profile_key):
        """
        Launch the chat state machine on "active" state.

        @param to_jid (JID): full JID for one2one, bare JID for MUC
        @param mess_type (str): "one2one" or "groupchat"
        @param profile (str): %(doc_profile)s
        """
        profile = self.host.memory.getProfileName(profile_key)
        if profile is None:
            raise exceptions.ProfileUnknownError
        self.__chatStateInit(to_jid, mess_type, profile)
        self.map[profile][to_jid]._onEvent("active")

    def chatStateComposing(self, to_jid_s, profile_key):
        """Move to the "composing" state when required.

        Since this method is called from the front-end, it needs to check the
        values of the parameter "Send chat state notifications" and the entity
        data associated to the target JID.

        @param to_jid_s (str): contact full JID as a string
        @param profile_key (str): %(doc_profile_key)s
        """
        # TODO: try to optimize this method which is called often
        profile = self.host.memory.getProfileName(profile_key)
        if profile is None:
            raise exceptions.ProfileUnknownError
        to_jid = JID(to_jid_s)
        if self.__isMUC(to_jid, profile):
            to_jid = to_jid.userhostJID()
        elif not to_jid.resource:
            to_jid.resource = self.host.memory.getLastResource(to_jid, profile)
        if not self.__checkActivation(to_jid, forceEntityData=False, profile=profile):
            return
        try:
            self.map[profile][to_jid]._onEvent("composing")
        except (KeyError, AttributeError):
            # no message has been sent/received since the notifications
            # have been enabled, it's better to wait for a first one
            pass


class ChatStateMachine:
    """
    This class represents a chat state, between one profile and
    one target contact. A timer is used to move from one state
    to the other. The initialization is done through the "active"
    state which is internally set when a message is sent. The state
    "composing" can be set externally (through the bridge by a
    frontend). Other states are automatically set with the timer.
    """

    def __init__(self, host, to_jid, mess_type, profile):
        """
        Initialization need to store the target, message type
        and a profile for sending later messages.
        """
        self.host = host
        self.to_jid = to_jid
        self.mess_type = mess_type
        self.profile = profile
        self.state = None
        self.timer = None

    def _onEvent(self, state):
        """
        Move to the specified state, eventually send the
        notification to the contact (the "active" state is
        automatically sent with each message) and set the timer.
        """
        assert(state in TRANSITIONS)
        transition = TRANSITIONS[state]
        assert("next_state" in transition and "delay" in transition)

        if state != self.state and state != "active":
            if state != 'gone' or self.mess_type != 'groupchat':
                # send a new message without body
                log.debug(u"sending state '{state}' to {jid}".format(state=state, jid=self.to_jid.full()))
                client = self.host.getClient(self.profile)
                mess_data = {'message': None,
                             'type': self.mess_type,
                             'from': client.jid,
                             'to': self.to_jid,
                             'subject': None
                             }
                self.host.generateMessageXML(mess_data)
                mess_data['xml'].addElement(state, NS_CHAT_STATES)
                client.xmlstream.send(mess_data['xml'])

        self.state = state
        if self.timer is not None:
            self.timer.cancel()

        if transition["next_state"] and transition["delay"] > 0:
            self.timer = Timer(transition["delay"], self._onEvent, [transition["next_state"]])
            self.timer.start()


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

    def __init__(self, plugin_parent, profile):
        self.plugin_parent = plugin_parent
        self.host = plugin_parent.host
        self.profile = profile

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

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