changeset 636:7ea6d5a86e58

plugin XEP-0085: Chat State Notifications - new "options" parameter to send chat states - plugin command export: messages without body are now delivered (since all the chat states other than "active" need them)
author souliane <souliane@mailoo.org>
date Thu, 05 Sep 2013 20:48:47 +0200
parents eff8772fd472
children 3b02554d4c8b
files frontends/src/bridge/DBus.py frontends/src/quick_frontend/quick_app.py src/bridge/DBus.py src/bridge/bridge_constructor/bridge_template.ini src/core/sat_main.py src/core/xmpp.py src/plugins/plugin_exp_command_export.py src/plugins/plugin_xep_0085.py
diffstat 8 files changed, 368 insertions(+), 33 deletions(-) [+]
line wrap: on
line diff
--- a/frontends/src/bridge/DBus.py	Sun Sep 08 19:12:59 2013 +0200
+++ b/frontends/src/bridge/DBus.py	Thu Sep 05 20:48:47 2013 +0200
@@ -196,8 +196,8 @@
     def registerNewAccount(self, login, password, email, host, port=5222):
         return unicode(self.db_core_iface.registerNewAccount(login, password, email, host, port))
 
-    def sendMessage(self, to_jid, message, subject='', mess_type="auto", profile_key="@DEFAULT@"):
-        return self.db_core_iface.sendMessage(to_jid, message, subject, mess_type, profile_key)
+    def sendMessage(self, to_jid, message, subject='', mess_type="auto", options={}, profile_key="@NONE@"):
+        return self.db_core_iface.sendMessage(to_jid, message, subject, mess_type, options, profile_key)
 
     def setParam(self, name, value, category, profile_key="@DEFAULT@"):
         return self.db_core_iface.setParam(name, value, category, profile_key)
--- a/frontends/src/quick_frontend/quick_app.py	Sun Sep 08 19:12:59 2013 +0200
+++ b/frontends/src/quick_frontend/quick_app.py	Thu Sep 05 20:48:47 2013 +0200
@@ -83,6 +83,7 @@
         self.bridge.register("quizGameAnswerResult", self.quizGameAnswerResult, "plugin")
         self.bridge.register("quizGameTimerExpired", self.quizGameTimerExpired, "plugin")
         self.bridge.register("quizGameTimerRestarted", self.quizGameTimerRestarted, "plugin")
+        self.bridge.register("chatStateReceived", self.chatStateReceived, "plugin")
 
         self.current_action_ids = set()
         self.current_action_ids_cb = {}
@@ -281,11 +282,11 @@
         timestamp = extra.get('archive')
         self.chat_wins[win.short].printMessage(from_jid, msg, profile, float(timestamp) if timestamp else '')
 
-    def sendMessage(self, to_jid, message, subject='', mess_type="auto", profile_key="@DEFAULT@"):
+    def sendMessage(self, to_jid, message, subject='', mess_type="auto", options={}, profile_key="@NONE@"):
         if to_jid.startswith(const_PRIVATE_PREFIX):
             to_jid = unescapePrivate(to_jid)
             mess_type = "chat"
-        self.bridge.sendMessage(to_jid, message, subject, mess_type, profile_key)
+        self.bridge.sendMessage(to_jid, message, subject, mess_type, options, profile_key)
 
     def newAlert(self, msg, title, alert_type, profile):
         if not self.check_profile(profile):
--- a/src/bridge/DBus.py	Sun Sep 08 19:12:59 2013 +0200
+++ b/src/bridge/DBus.py	Thu Sep 05 20:48:47 2013 +0200
@@ -385,10 +385,10 @@
         return self._callback("registerNewAccount", unicode(login), unicode(password), unicode(email), unicode(host), port)
 
     @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX,
-                         in_signature='sssss', out_signature='',
+                         in_signature='ssssa{ss}s', out_signature='',
                          async_callbacks=None)
-    def sendMessage(self, to_jid, message, subject='', mess_type="auto", profile_key="@DEFAULT@"):
-        return self._callback("sendMessage", unicode(to_jid), unicode(message), unicode(subject), unicode(mess_type), unicode(profile_key))
+    def sendMessage(self, to_jid, message, subject='', mess_type="auto", options={}, profile_key="@NONE@"):
+        return self._callback("sendMessage", unicode(to_jid), unicode(message), unicode(subject), unicode(mess_type), options, unicode(profile_key))
 
     @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX,
                          in_signature='ssss', out_signature='',
--- a/src/bridge/bridge_constructor/bridge_template.ini	Sun Sep 08 19:12:59 2013 +0200
+++ b/src/bridge/bridge_constructor/bridge_template.ini	Thu Sep 05 20:48:47 2013 +0200
@@ -344,17 +344,19 @@
 [sendMessage]
 type=method
 category=core
-sig_in=sssss
+sig_in=ssssa{ss}s
 sig_out=
 param_2_default=''
 param_3_default="auto"
-param_4_default="@DEFAULT@"
+param_4_default={}
+param_5_default="@NONE@"
 doc=Send a message
 doc_param_0=to_jid: JID of the recipient
 doc_param_1=message: body of the message
 doc_param_2=subject: Subject of the message ('' if no subject)
 doc_param_3=mess_type: Type of the message (cf RFC 3921 #2.1.1) or "auto" for automatic type detection
-doc_param_4=%(doc_profile_key)s
+doc_param_4=options: optional data that can be used by a plugin to build more specific messages 
+doc_param_5=%(doc_profile_key)s
 
 [setPresence]
 type=method
--- a/src/core/sat_main.py	Sun Sep 08 19:12:59 2013 +0200
+++ b/src/core/sat_main.py	Thu Sep 05 20:48:47 2013 +0200
@@ -469,21 +469,24 @@
             ret.append((conf_id, conf_type, data))
         return ret
 
-    def _sendMessage(self, to_s, msg, subject=None, mess_type='auto', profile_key='@NONE@'):
+    def _sendMessage(self, to_s, msg, subject=None, mess_type='auto', options={}, profile_key='@NONE@'):
         to_jid = jid.JID(to_s)
-        self.sendMessage(to_jid, msg, subject, mess_type, profile_key=profile_key)
-    
-    def sendMessage(self, to_jid, msg, subject=None, mess_type='auto', no_trigger = False, profile_key='@NONE@'):
+        self.sendMessage(to_jid, msg, subject, mess_type, options=options, profile_key=profile_key)
+
+    def sendMessage(self, to_jid, msg, subject=None, mess_type='auto', options={}, no_trigger=False, profile_key='@NONE@'):
         #FIXME: check validity of recipient
         profile = self.memory.getProfileName(profile_key)
         assert(profile)
         client = self.profiles[profile]
         current_jid = client.jid
+        if options is None:
+            options = {}
         mess_data = {  # we put data in a dict, so trigger methods can change them
             "to": to_jid,
             "message": msg,
             "subject": subject,
-            "type": mess_type
+            "type": mess_type,
+            "options": options,
         }
 
         if mess_data["type"] == "auto":
@@ -517,12 +520,28 @@
         message["type"] = mess_data["type"]
         if mess_data["subject"]:
             message.addElement("subject", None, subject)
-        message.addElement("body", None, mess_data["message"])
+        # message without body are used to send chat states
+        if mess_data["message"]:
+            message.addElement("body", None, mess_data["message"])
+        if not no_trigger:
+            if not self.trigger.point("sendMessageXml", message,
+                                      mess_data, profile):
+                return
         client.xmlstream.send(message)
         if mess_data["type"] != "groupchat":
-            self.memory.addToHistory(current_jid, mess_data['to'], unicode(mess_data["message"]), unicode(mess_data["type"]), profile=profile)  # we don't add groupchat message to history, as we get them back
-                                                                                              # and they will be added then
-            self.bridge.newMessage(message['from'], unicode(mess_data["message"]), mess_type=mess_data["type"], to_jid=message['to'], extra={}, profile=profile)  # We send back the message, so all clients are aware of it
+            # we don't add groupchat message to history, as we get them back
+            # and they will be added then
+            self.memory.addToHistory(current_jid, mess_data['to'],
+                                     unicode(mess_data["message"]),
+                                     unicode(mess_data["type"]),
+                                     profile=profile)
+            # We send back the message, so all clients are aware of it
+            if mess_data["message"]:
+                self.bridge.newMessage(message['from'],
+                                       unicode(mess_data["message"]),
+                                       mess_type=mess_data["type"],
+                                       to_jid=message['to'], extra={},
+                                       profile=profile)
 
     def setPresence(self, to="", show="", priority=0, statuses={}, profile_key='@DEFAULT@'):
         """Send our presence information"""
--- a/src/core/xmpp.py	Sun Sep 08 19:12:59 2013 +0200
+++ b/src/core/xmpp.py	Thu Sep 05 20:48:47 2013 +0200
@@ -108,23 +108,27 @@
         debug(_(u"got message from: %s"), message["from"])
         if not self.host.trigger.point("MessageReceived", message, profile=self.parent.profile):
             return
+        # set message body to empty string by default, and later
+        # also forward message without body (chat state notification...)
+        mess_body = ""
         for e in message.elements():
             if e.name == "body":
-                mess_type = message['type'] if message.hasAttribute('type') else 'normal'
                 mess_body = e.children[0] if e.children else ""
-                try:
-                    _delay = delay.Delay.fromElement(filter(lambda elm: elm.name == 'delay', message.elements())[0])
-                    timestamp = timegm(_delay.stamp.utctimetuple())
-                    extra = {"archive": str(timestamp)}
-                    if mess_type != 'groupchat':  # XXX: we don't save delayed messages in history for groupchats
-                        #TODO: add delayed messages to history if they aren't already in it
-                        self.host.memory.addToHistory(jid.JID(message["from"]), jid.JID(message["to"]), mess_body, mess_type, timestamp, profile=self.parent.profile)
-                except IndexError:
-                    extra = {}
-                    self.host.memory.addToHistory(jid.JID(message["from"]), jid.JID(message["to"]), mess_body, mess_type, profile=self.parent.profile)
-                self.host.bridge.newMessage(message["from"], mess_body, mess_type, message['to'], extra, profile=self.parent.profile)
                 break
 
+        mess_type = message['type'] if message.hasAttribute('type') else 'normal'
+        try:
+            _delay = delay.Delay.fromElement(filter(lambda elm: elm.name == 'delay', message.elements())[0])
+            timestamp = timegm(_delay.stamp.utctimetuple())
+            extra = {"archive": str(timestamp)}
+            if mess_type != 'groupchat':  # XXX: we don't save delayed messages in history for groupchats
+                #TODO: add delayed messages to history if they aren't already in it
+                self.host.memory.addToHistory(jid.JID(message["from"]), jid.JID(message["to"]), mess_body, mess_type, timestamp, profile=self.parent.profile)
+        except IndexError:
+            extra = {}
+            self.host.memory.addToHistory(jid.JID(message["from"]), jid.JID(message["to"]), mess_body, mess_type, profile=self.parent.profile)
+        self.host.bridge.newMessage(message["from"], mess_body, mess_type, message['to'], extra, profile=self.parent.profile)
+
 
 class SatRosterProtocol(xmppim.RosterClientProtocol):
 
--- a/src/plugins/plugin_exp_command_export.py	Sun Sep 08 19:12:59 2013 +0200
+++ b/src/plugins/plugin_exp_command_export.py	Thu Sep 05 20:48:47 2013 +0200
@@ -107,8 +107,9 @@
         try:
             body = [e for e in message.elements() if e.name == 'body'][0]
         except IndexError:
-            warning("Received message without body")
-            return False
+            # do not block message without body (chat state notification...)
+            warning("No body element found in message, following normal behaviour")
+            return True
 
         mess_data = unicode(body) + '\n'
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/plugins/plugin_xep_0085.py	Thu Sep 05 20:48:47 2013 +0200
@@ -0,0 +1,308 @@
+#!/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 []