view src/plugins/plugin_xep_0085.py @ 641:49587e170f53

core: added the security_limit to setParam - params with a security greater than security_limit can not be modified - special value: security_limit < 0 disable the check (all params can be modified)
author souliane <souliane@mailoo.org>
date Sat, 07 Sep 2013 02:03:17 +0200
parents 7ea6d5a86e58
children 262d9d9ad27a
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 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 import exceptions
from logging import debug, info, error
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.domish import generateElementsNamed

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 = "Chat State Notifications"
PARAM_NAME = "Enabled"

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(_("CSN plugin initialization"))
        self.host = host

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

        # triggers from core
        host.trigger.add("MessageReceived", self.messageReceivedTrigger)
        host.trigger.add("sendMessageXml", self.sendMessageXmlTrigger)
        host.trigger.add("paramUpdateTrigger", self.paramUpdateTrigger)
        #TODO: handle profile disconnexion (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 and reset the chat state display
        if the notification has been disabled. Parameter "entity_jid"
        could be @ALL@ to update all entities.
        """
        self.host.memory.updateEntityData(entity_jid, PARAM_KEY, value, profile)
        if not value or value == "@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 (value
        different then "true" would delete the entity data).
        """
        if (category, name) == (PARAM_KEY, PARAM_NAME):
            self.updateEntityData("@ALL@", True if value == "true" else "@NONE@", profile)

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

        try:
            generateElementsNamed(message.children, name="body").next()
            from_jid = JID(message.getAttribute("from"))
            try:
                generateElementsNamed(message.children, name="active").next()
                # contact enabled Chat State Notifications
                self.updateEntityData(from_jid, True, profile)
                # init to send following "composing" state
                self.__chatStateInit(from_jid, message.getAttribute("type"), profile)
            except StopIteration:
                # contact didn't enable Chat State Notifications
                self.updateEntityData(from_jid, False, profile)
        except StopIteration:
            pass

        state_list = [child.name for child in message.children 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
            self.host.bridge.chatStateReceived(message.getAttribute("from"),
                                               state, profile)
            break
        return True

    def sendMessageXmlTrigger(self, message, mess_data, profile):
        """
        Eventually add the chat state to the message and initiate
        the state machine when sending an "active" state.
        """
        if not self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile):
            return True

        # check if notifications should be sent to this contact
        contact_enabled = True
        to_jid = JID(message.getAttribute("to"))
        try:
            contact_enabled = self.host.memory.getEntityData(
                to_jid, [PARAM_KEY], profile)[PARAM_KEY]
        except (exceptions.UnknownEntityError, KeyError):
            # enable it for the first time
            self.updateEntityData(to_jid, True, profile)
        if not contact_enabled:
            return True
        try:
            # message with a body always mean active state
            generateElementsNamed(message.children, 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["options"]:
                state = mess_data["options"]["chat_state"]
                assert(state in CHAT_STATES)
                message.addElement(state, NS_CHAT_STATES)
        return True

    def __chatStateInit(self, to_jid, mess_type, profile):
        """
        Data initialization for the chat state machine.
        """
        # TODO: use also the resource in map key
        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 resource in map key
        to_jid = to_jid.userhostJID()
        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.
        """
        # TODO: use also the resource in map key
        to_jid = JID(to_jid_s).userhostJID()
        profile = self.host.memory.getProfileName(profile_key)
        if profile is None:
            raise exceptions.ProfileUnknownError
        try:
            self.map[profile][to_jid]._onEvent("composing")
        except:
            return


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":
            # send a new message without body
            self.host.sendMessage(self.to_jid,
                                  '',
                                  '',
                                  self.mess_type,
                                  options={"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 []