- 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)
308 lines
12 KiB
Python
Executable file
308 lines
12 KiB
Python
Executable file
#!/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 []
|