view src/plugins/plugin_xep_0085.py @ 1005:b4af31a8a4f2

core (logs): added formatting, name filter and outputs management: - formatting is inspired from, and use when possible, standard logging. "message", "levelname", and "name" are the only format managed, depending on backend more can be managed (standard backend formats are specified in official python logging doc) - name filter use regular expressions. It's possible to log only plugins with SAT_LOG_LOGGER="^sat.plugins". To log only XEPs 96 & 65, we can use SAT_LOG_LOGGER='(xep_0095|xep_0065)' - output management use a particular syntax: - output handler are name with "//", so far there are "//default" (most of time stderr), "//memory" and "//file" - options can be specified in parenthesis, e.g. "//memory(50)" mean a 50 lines memory buffer (50 is the current default, so that's equivalent to "//memory") - several handlers can be specified: "//file(/tmp/sat.log)//default" will use the default logging + a the /tmp/sat.log file - if there is only one handler, it use the file handler: "/tmp/sat.log" is the same as "//file(/tmp/sat.log)" - not finished, need more work for twisted and basic backends
author Goffi <goffi@goffi.org>
date Mon, 05 May 2014 18:58:34 +0200
parents 301b342c697a
children 5ff9f9af9d1f
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

        # 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 []