Mercurial > libervia-backend
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 []