view src/plugins/plugin_xep_0085.py @ 922:c897c8d321b3

core: sendMessageTrigger now manage pre and post treatments, which happen before or after XML generation
author Goffi <goffi@goffi.org>
date Sat, 22 Mar 2014 15:34:05 +0100
parents 1a759096ccbd
children 301b342c697a
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 logging import info
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):
        info(_("Chat State Notifications plugin initialization"))
        self.host = host

        # 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)
        # TODO: handle profile disconnection (free memory in entity data)

        # 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 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
        """
        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"))
        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)
                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 __checkActivation(self, to_jid, forceEntityData, profile):
        """
        @param to_joid: the contact's JID
        @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
        try:
            type_ = self.host.memory.getEntityData(to_jid, ['type'], profile)['type']
            if type_ == 'groupchat':  # always send to groupchat
                return True
        except (exceptions.UnknownEntityError, KeyError):
            pass  # private chat
        try:
            return self.host.memory.getEntityData(to_jid, [ENTITY_KEY], profile)[ENTITY_KEY]
        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.
        """
        # TODO: use also the resource in map key (not for groupchat)
        to_jid = to_jid.userhostJID()
        if mess_type is None:
            return
        if not hasattr(self, "map"):
            self.map = {}
        profile_map = self.map.setdefault(profile, {})
        if not to_jid 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.
        """
        # TODO: use also the JID resource in the map key (not for groupchat)
        to_jid = to_jid.userhostJID()
        profile = self.host.memory.getProfileName(profile_key)
        if profile is None:
            raise exceptions.ProfileUnknownError
            return
        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. 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.
        TODO: try to optimize this method which is called often
        """
        # TODO: use also the JID resource in the map key (not for groupchat)
        to_jid = JID(to_jid_s).userhostJID()
        profile = self.host.memory.getProfileName(profile_key)
        if profile is None:
            raise exceptions.ProfileUnknownError
            return
        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.
        """
        if state != self.state and state != "active":
            if state != 'gone' or self.mess_type != 'groupchat':
                # send a new message without body
                self.host.sendMessage(self.to_jid, '', '', self.mess_type,
                                      extra={"chat_state": state},
                                      profile_key=self.profile)
        self.state = state
        if not self.timer is None:
            self.timer.cancel()

        if not state in TRANSITIONS:
            return
        if not "next_state" in TRANSITIONS[state]:
            return
        if not "delay" in TRANSITIONS[state]:
            return
        next_state = TRANSITIONS[state]["next_state"]
        delay = TRANSITIONS[state]["delay"]
        if next_state == "" or delay < 0:
            return
        self.timer = Timer(delay, self._onEvent, [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 []