diff --git a/frontends/src/bridge/DBus.py b/frontends/src/bridge/DBus.py
index 8ddd77f9..25d018c7 100644
--- a/frontends/src/bridge/DBus.py
+++ b/frontends/src/bridge/DBus.py
@@ -196,8 +196,8 @@ class DBusBridgeFrontend(BridgeFrontend):
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)
diff --git a/frontends/src/quick_frontend/quick_app.py b/frontends/src/quick_frontend/quick_app.py
index ac767dd5..48ed3df8 100644
--- a/frontends/src/quick_frontend/quick_app.py
+++ b/frontends/src/quick_frontend/quick_app.py
@@ -83,6 +83,7 @@ class QuickApp(object):
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 @@ class QuickApp(object):
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):
diff --git a/src/bridge/DBus.py b/src/bridge/DBus.py
index 34f76004..04996b3d 100755
--- a/src/bridge/DBus.py
+++ b/src/bridge/DBus.py
@@ -385,10 +385,10 @@ class DbusObject(dbus.service.Object):
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='',
diff --git a/src/bridge/bridge_constructor/bridge_template.ini b/src/bridge/bridge_constructor/bridge_template.ini
index 3aff312c..02b62d98 100644
--- a/src/bridge/bridge_constructor/bridge_template.ini
+++ b/src/bridge/bridge_constructor/bridge_template.ini
@@ -344,17 +344,19 @@ doc_return=List of confirmation request data, same as for [askConfirmation]
[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
diff --git a/src/core/sat_main.py b/src/core/sat_main.py
index 48064124..bd29ce9e 100644
--- a/src/core/sat_main.py
+++ b/src/core/sat_main.py
@@ -469,21 +469,24 @@ class SAT(service.Service):
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 @@ class SAT(service.Service):
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"""
diff --git a/src/core/xmpp.py b/src/core/xmpp.py
index 000c8e1c..8b25a5c4 100644
--- a/src/core/xmpp.py
+++ b/src/core/xmpp.py
@@ -108,23 +108,27 @@ class SatMessageProtocol(xmppim.MessageProtocol):
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):
diff --git a/src/plugins/plugin_exp_command_export.py b/src/plugins/plugin_exp_command_export.py
index 89079843..72ec0230 100755
--- a/src/plugins/plugin_exp_command_export.py
+++ b/src/plugins/plugin_exp_command_export.py
@@ -107,8 +107,9 @@ class CommandExport(object):
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'
diff --git a/src/plugins/plugin_xep_0085.py b/src/plugins/plugin_xep_0085.py
new file mode 100755
index 00000000..0d355e8e
--- /dev/null
+++ b/src/plugins/plugin_xep_0085.py
@@ -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 .
+
+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 = """
+
+
+
+
+
+
+
+ """ % {
+ '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 []