433 lines
17 KiB
Python
433 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
|
|
|
|
# SAT plugin for Chat State Notifications Protocol (xep-0085)
|
|
# Copyright (C) 2009-2016 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 libervia.backend.core.i18n import _
|
|
from libervia.backend.core.constants import Const as C
|
|
from libervia.backend.core import exceptions
|
|
from libervia.backend.core.log import getLogger
|
|
|
|
log = getLogger(__name__)
|
|
from wokkel import disco, iwokkel
|
|
from zope.interface import implementer
|
|
from twisted.words.protocols.jabber.jid import JID
|
|
|
|
try:
|
|
from twisted.words.protocols.xmlstream import XMPPHandler
|
|
except ImportError:
|
|
from wokkel.subprotocols import XMPPHandler
|
|
from twisted.words.xish import domish
|
|
from twisted.internet import reactor
|
|
from twisted.internet import error as internet_error
|
|
|
|
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 = "Notifications"
|
|
PARAM_NAME = "Enable chat state notifications"
|
|
ENTITY_KEY = PARAM_KEY + "_" + PARAM_NAME
|
|
DELETE_VALUE = "DELETE"
|
|
|
|
PLUGIN_INFO = {
|
|
C.PI_NAME: "Chat State Notifications Protocol Plugin",
|
|
C.PI_IMPORT_NAME: "XEP-0085",
|
|
C.PI_TYPE: "XEP",
|
|
C.PI_PROTOCOLS: ["XEP-0085"],
|
|
C.PI_DEPENDENCIES: [],
|
|
C.PI_MAIN: "XEP_0085",
|
|
C.PI_HANDLER: "yes",
|
|
C.PI_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):
|
|
log.info(_("Chat State Notifications plugin initialization"))
|
|
self.host = host
|
|
self.map = {} # FIXME: would be better to use client instead of mapping profile to data
|
|
|
|
# parameter value is retrieved before each use
|
|
host.memory.update_params(self.params)
|
|
|
|
# triggers from core
|
|
host.trigger.add("message_received", self.message_received_trigger)
|
|
host.trigger.add("sendMessage", self.send_message_trigger)
|
|
host.trigger.add("param_update_trigger", self.param_update_trigger)
|
|
|
|
# args: to_s (jid as string), profile
|
|
host.bridge.add_method(
|
|
"chat_state_composing",
|
|
".plugin",
|
|
in_sign="ss",
|
|
out_sign="",
|
|
method=self.chat_state_composing,
|
|
)
|
|
|
|
# args: from (jid as string), state in CHAT_STATES, profile
|
|
host.bridge.add_signal("chat_state_received", ".plugin", signature="sss")
|
|
|
|
def get_handler(self, client):
|
|
return XEP_0085_handler(self, client.profile)
|
|
|
|
def profile_disconnected(self, client):
|
|
"""Eventually send a 'gone' state to all one2one contacts."""
|
|
profile = client.profile
|
|
if profile not in self.map:
|
|
return
|
|
for to_jid in self.map[profile]:
|
|
# FIXME: the "unavailable" presence stanza is received by to_jid
|
|
# before the chat state, so it will be ignored... find a way to
|
|
# actually defer the disconnection
|
|
self.map[profile][to_jid]._onEvent("gone")
|
|
del self.map[profile]
|
|
|
|
def update_cache(self, entity_jid, value, profile):
|
|
"""Update the entity data of the given profile for one or all contacts.
|
|
Reset the chat state(s) display if the notification has been disabled.
|
|
|
|
@param entity_jid: contact's JID, or C.ENTITY_ALL to update all contacts.
|
|
@param value: True, False or DELETE_VALUE to delete the entity data
|
|
@param profile: current profile
|
|
"""
|
|
client = self.host.get_client(profile)
|
|
if value == DELETE_VALUE:
|
|
self.host.memory.del_entity_datum(client, entity_jid, ENTITY_KEY)
|
|
else:
|
|
self.host.memory.update_entity_data(
|
|
client, entity_jid, ENTITY_KEY, value
|
|
)
|
|
if not value or value == DELETE_VALUE:
|
|
# reinit chat state UI for this or these contact(s)
|
|
self.host.bridge.chat_state_received(entity_jid.full(), "", profile)
|
|
|
|
def param_update_trigger(self, name, value, category, type_, profile):
|
|
"""Reset all the existing chat state entity data associated with this profile after a parameter modification.
|
|
|
|
@param name: parameter name
|
|
@param value: "true" to activate the notifications, or any other value to delete it
|
|
@param category: parameter category
|
|
@param type_: parameter type
|
|
"""
|
|
if (category, name) == (PARAM_KEY, PARAM_NAME):
|
|
self.update_cache(
|
|
C.ENTITY_ALL, True if C.bool(value) else DELETE_VALUE, profile=profile
|
|
)
|
|
return False
|
|
return True
|
|
|
|
def message_received_trigger(self, client, message, post_treat):
|
|
"""
|
|
Update the entity cache when we receive a message with body.
|
|
Check for a chat state in the message and signal frontends.
|
|
"""
|
|
profile = client.profile
|
|
if not self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile):
|
|
return True
|
|
|
|
from_jid = JID(message.getAttribute("from"))
|
|
if self._is_muc(from_jid, profile):
|
|
from_jid = from_jid.userhostJID()
|
|
else: # update entity data for one2one chat
|
|
# assert from_jid.resource # FIXME: assert doesn't work on normal message from server (e.g. server announce), because there is no resource
|
|
try:
|
|
next(domish.generateElementsNamed(message.elements(), name="body"))
|
|
try:
|
|
next(domish.generateElementsNamed(message.elements(), name="active"))
|
|
# contact enabled Chat State Notifications
|
|
self.update_cache(from_jid, True, profile=profile)
|
|
except StopIteration:
|
|
if message.getAttribute("type") == "chat":
|
|
# contact didn't enable Chat State Notifications
|
|
self.update_cache(from_jid, False, profile=profile)
|
|
return True
|
|
except StopIteration:
|
|
pass
|
|
|
|
# send our next "composing" states to any MUC and to the contacts who enabled the feature
|
|
self._chat_state_init(from_jid, message.getAttribute("type"), profile)
|
|
|
|
state_list = [
|
|
child.name
|
|
for child in message.elements()
|
|
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
|
|
if state != "gone" or message.getAttribute("type") != "groupchat":
|
|
self.host.bridge.chat_state_received(
|
|
message.getAttribute("from"), state, profile
|
|
)
|
|
break
|
|
return True
|
|
|
|
def send_message_trigger(
|
|
self, client, mess_data, pre_xml_treatments, post_xml_treatments
|
|
):
|
|
"""
|
|
Eventually add the chat state to the message and initiate
|
|
the state machine when sending an "active" state.
|
|
"""
|
|
profile = client.profile
|
|
|
|
def treatment(mess_data):
|
|
message = mess_data["xml"]
|
|
to_jid = JID(message.getAttribute("to"))
|
|
if not self._check_activation(to_jid, forceEntityData=True, profile=profile):
|
|
return mess_data
|
|
try:
|
|
# message with a body always mean active state
|
|
next(domish.generateElementsNamed(message.elements(), name="body"))
|
|
message.addElement("active", NS_CHAT_STATES)
|
|
# launch the chat state machine (init the timer)
|
|
if self._is_muc(to_jid, profile):
|
|
to_jid = to_jid.userhostJID()
|
|
self._chat_state_active(to_jid, mess_data["type"], profile)
|
|
except StopIteration:
|
|
if "chat_state" in mess_data["extra"]:
|
|
state = mess_data["extra"].pop("chat_state")
|
|
assert state in CHAT_STATES
|
|
message.addElement(state, NS_CHAT_STATES)
|
|
return mess_data
|
|
|
|
post_xml_treatments.addCallback(treatment)
|
|
return True
|
|
|
|
def _is_muc(self, to_jid, profile):
|
|
"""Tell if that JID is a MUC or not
|
|
|
|
@param to_jid (JID): full or bare JID to check
|
|
@param profile (str): %(doc_profile)s
|
|
@return: bool
|
|
"""
|
|
client = self.host.get_client(profile)
|
|
try:
|
|
type_ = self.host.memory.get_entity_datum(
|
|
client, to_jid.userhostJID(), C.ENTITY_TYPE)
|
|
if type_ == C.ENTITY_TYPE_MUC:
|
|
return True
|
|
except (exceptions.UnknownEntityError, KeyError):
|
|
pass
|
|
return False
|
|
|
|
def _check_activation(self, to_jid, forceEntityData, profile):
|
|
"""
|
|
@param to_jid: the contact's full JID (or bare if you know it's a MUC)
|
|
@param forceEntityData: if set to True, a non-existing
|
|
entity data will be considered to be True (and initialized)
|
|
@param: current profile
|
|
@return: True if the notifications should be sent to this JID.
|
|
"""
|
|
client = self.host.get_client(profile)
|
|
# check if the parameter is active
|
|
if not self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile):
|
|
return False
|
|
# check if notifications should be sent to this contact
|
|
if self._is_muc(to_jid, profile):
|
|
return True
|
|
# FIXME: this assertion crash when we want to send a message to an online bare jid
|
|
# assert to_jid.resource or not self.host.memory.is_entity_available(to_jid, profile) # must either have a resource, or talk to an offline contact
|
|
try:
|
|
return self.host.memory.get_entity_datum(client, to_jid, ENTITY_KEY)
|
|
except (exceptions.UnknownEntityError, KeyError):
|
|
if forceEntityData:
|
|
# enable it for the first time
|
|
self.update_cache(to_jid, True, profile=profile)
|
|
return True
|
|
# wait for the first message before sending states
|
|
return False
|
|
|
|
def _chat_state_init(self, to_jid, mess_type, profile):
|
|
"""
|
|
Data initialization for the chat state machine.
|
|
|
|
@param to_jid (JID): full JID for one2one, bare JID for MUC
|
|
@param mess_type (str): "one2one" or "groupchat"
|
|
@param profile (str): %(doc_profile)s
|
|
"""
|
|
if mess_type is None:
|
|
return
|
|
profile_map = self.map.setdefault(profile, {})
|
|
if to_jid not in profile_map:
|
|
machine = ChatStateMachine(self.host, to_jid, mess_type, profile)
|
|
self.map[profile][to_jid] = machine
|
|
|
|
def _chat_state_active(self, to_jid, mess_type, profile_key):
|
|
"""
|
|
Launch the chat state machine on "active" state.
|
|
|
|
@param to_jid (JID): full JID for one2one, bare JID for MUC
|
|
@param mess_type (str): "one2one" or "groupchat"
|
|
@param profile (str): %(doc_profile)s
|
|
"""
|
|
profile = self.host.memory.get_profile_name(profile_key)
|
|
if profile is None:
|
|
raise exceptions.ProfileUnknownError
|
|
self._chat_state_init(to_jid, mess_type, profile)
|
|
self.map[profile][to_jid]._onEvent("active")
|
|
|
|
def chat_state_composing(self, to_jid_s, profile_key):
|
|
"""Move to the "composing" state when required.
|
|
|
|
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.
|
|
|
|
@param to_jid_s (str): contact full JID as a string
|
|
@param profile_key (str): %(doc_profile_key)s
|
|
"""
|
|
# TODO: try to optimize this method which is called often
|
|
client = self.host.get_client(profile_key)
|
|
to_jid = JID(to_jid_s)
|
|
if self._is_muc(to_jid, client.profile):
|
|
to_jid = to_jid.userhostJID()
|
|
elif not to_jid.resource:
|
|
to_jid.resource = self.host.memory.main_resource_get(client, to_jid)
|
|
if not self._check_activation(
|
|
to_jid, forceEntityData=False, profile=client.profile
|
|
):
|
|
return
|
|
try:
|
|
self.map[client.profile][to_jid]._onEvent("composing")
|
|
except (KeyError, AttributeError):
|
|
# no message has been sent/received since the notifications
|
|
# have been enabled, it's better to wait for a first one
|
|
pass
|
|
|
|
|
|
class ChatStateMachine(object):
|
|
"""
|
|
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.
|
|
"""
|
|
assert state in TRANSITIONS
|
|
transition = TRANSITIONS[state]
|
|
assert "next_state" in transition and "delay" in transition
|
|
|
|
if state != self.state and state != "active":
|
|
if state != "gone" or self.mess_type != "groupchat":
|
|
# send a new message without body
|
|
log.debug(
|
|
"sending state '{state}' to {jid}".format(
|
|
state=state, jid=self.to_jid.full()
|
|
)
|
|
)
|
|
client = self.host.get_client(self.profile)
|
|
mess_data = {
|
|
"from": client.jid,
|
|
"to": self.to_jid,
|
|
"uid": "",
|
|
"message": {},
|
|
"type": self.mess_type,
|
|
"subject": {},
|
|
"extra": {},
|
|
}
|
|
client.generate_message_xml(mess_data)
|
|
mess_data["xml"].addElement(state, NS_CHAT_STATES)
|
|
client.send(mess_data["xml"])
|
|
|
|
self.state = state
|
|
try:
|
|
self.timer.cancel()
|
|
except (internet_error.AlreadyCalled, AttributeError):
|
|
pass
|
|
|
|
if transition["next_state"] and transition["delay"] > 0:
|
|
self.timer = reactor.callLater(
|
|
transition["delay"], self._onEvent, transition["next_state"]
|
|
)
|
|
|
|
|
|
@implementer(iwokkel.IDisco)
|
|
class XEP_0085_handler(XMPPHandler):
|
|
|
|
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 []
|