view src/plugins/ @ 654:5c5cf5bca240

plugin XEP-0085: improvement for sending "composing" state
author souliane <>
date Thu, 26 Sep 2013 10:44:57 +0200
parents 262d9d9ad27a
children b6c22d9f593a
line wrap: on
line source

# -*- coding: utf-8 -*-

# SAT plugin for Chat State Notifications Protocol (xep-0085)
# Copyright (C) 2009, 2010, 2011, 2012, 2013 Adrien Cossa (

# 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
# 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 <>.

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
    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"
CHAT_STATES = ["active", "inactive", "gone", "composing", "paused"]
MESSAGE_TYPES = ["chat", "groupchat"]
PARAM_KEY = "Notifications"
PARAM_NAME = "Enable chat state notifications"

    "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.
    "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.

class XEP_0085(object):
    Implementation for XEP 0085
    params = """
    <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_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")) = host

        # parameter value is retrieved before each use

        # 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.
        """, ENTITY_KEY, value, profile)
        if not value or value == "@NONE@":
            # disable chat state for this or these contact(s)
  , "", 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, PARAM_KEY, profile_key=profile):
            return True

            generateElementsNamed(message.children, name="body").next()
            from_jid = JID(message.getAttribute("from"))
                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:

        state_list = [ for child in message.children if
                      message.getAttribute("type") in MESSAGE_TYPES
                      and in CHAT_STATES
                      and child.defaultUri == NS_CHAT_STATES]
        for state in state_list:
            # there must be only one state according to the XEP
                                               state, profile)
        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, 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"))
            contact_enabled =
                to_jid, [ENTITY_KEY], profile)[ENTITY_KEY]
        except (exceptions.UnknownEntityError, KeyError):
            # enable it for the first time
            self.updateEntityData(to_jid, True, profile)
        if not contact_enabled:
            return True
            # 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:
        if not hasattr(self, "map"):
   = {}
        profile_map =, {})
        if not to_jid in profile_map:
            machine = ChatStateMachine(, to_jid,
                                       mess_type, profile)
  [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
        to_jid = to_jid.userhostJID()
        profile =
        if profile is None:
            raise exceptions.ProfileUnknownError
        self.__chatStateInit(to_jid, mess_type, profile)[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
        # check if the parameter is active
        if not, PARAM_KEY, profile_key=profile_key):
        # TODO: use also the JID resource in the map key
        to_jid = JID(to_jid_s).userhostJID()
        profile =
        if profile is None:
            raise exceptions.ProfileUnknownError
        # check if notifications should be sent to this contact
        contact_enabled = True
            contact_enabled =
                to_jid, [ENTITY_KEY], profile)[ENTITY_KEY]
        except (exceptions.UnknownEntityError, KeyError):
            # wait for the first message before sending states
        if not contact_enabled:
            return True
        # now we are sure that the state should be sent[profile][to_jid]._onEvent("composing")

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.
        """ = 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
                                  options={"chat_state": state},
        self.state = state
        if not self.timer is None:

        if not state in TRANSITIONS:
        if not "next_state" in TRANSITIONS[state]:
        if not "delay" in TRANSITIONS[state]:
        next_state = TRANSITIONS[state]["next_state"]
        delay = TRANSITIONS[state]["delay"]
        if next_state == "" or delay < 0:
        self.timer = Timer(delay, self._onEvent, [next_state])

class XEP_0085_handler(XMPPHandler):

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

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

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