comparison sat/plugins/plugin_xep_0085.py @ 2562:26edcf3a30eb

core, setup: huge cleaning: - moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention - move twisted directory to root - removed all hacks from setup.py, and added missing dependencies, it is now clean - use https URL for website in setup.py - removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed - renamed sat.sh to sat and fixed its installation - added python_requires to specify Python version needed - replaced glib2reactor which use deprecated code by gtk3reactor sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author Goffi <goffi@goffi.org>
date Mon, 02 Apr 2018 19:44:50 +0200
parents src/plugins/plugin_xep_0085.py@33c8c4973743
children 56f94936df1e
comparison
equal deleted inserted replaced
2561:bd30dc3ffe5a 2562:26edcf3a30eb
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # SAT plugin for Chat State Notifications Protocol (xep-0085)
5 # Copyright (C) 2009-2016 Adrien Cossa (souliane@mailoo.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from sat.core.i18n import _
21 from sat.core.constants import Const as C
22 from sat.core import exceptions
23 from sat.core.log import getLogger
24 log = getLogger(__name__)
25 from wokkel import disco, iwokkel
26 from zope.interface import implements
27 from twisted.words.protocols.jabber.jid import JID
28 try:
29 from twisted.words.protocols.xmlstream import XMPPHandler
30 except ImportError:
31 from wokkel.subprotocols import XMPPHandler
32 from twisted.words.xish import domish
33 from twisted.internet import reactor
34 from twisted.internet import error as internet_error
35
36 NS_XMPP_CLIENT = "jabber:client"
37 NS_CHAT_STATES = "http://jabber.org/protocol/chatstates"
38 CHAT_STATES = ["active", "inactive", "gone", "composing", "paused"]
39 MESSAGE_TYPES = ["chat", "groupchat"]
40 PARAM_KEY = "Notifications"
41 PARAM_NAME = "Enable chat state notifications"
42 ENTITY_KEY = PARAM_KEY + "_" + PARAM_NAME
43 DELETE_VALUE = "DELETE"
44
45 PLUGIN_INFO = {
46 C.PI_NAME: "Chat State Notifications Protocol Plugin",
47 C.PI_IMPORT_NAME: "XEP-0085",
48 C.PI_TYPE: "XEP",
49 C.PI_PROTOCOLS: ["XEP-0085"],
50 C.PI_DEPENDENCIES: [],
51 C.PI_MAIN: "XEP_0085",
52 C.PI_HANDLER: "yes",
53 C.PI_DESCRIPTION: _("""Implementation of Chat State Notifications Protocol""")
54 }
55
56
57 # Describe the internal transitions that are triggered
58 # by a timer. Beside that, external transitions can be
59 # runned to target the states "active" or "composing".
60 # Delay is specified here in seconds.
61 TRANSITIONS = {
62 "active": {"next_state": "inactive", "delay": 120},
63 "inactive": {"next_state": "gone", "delay": 480},
64 "gone": {"next_state": "", "delay": 0},
65 "composing": {"next_state": "paused", "delay": 30},
66 "paused": {"next_state": "inactive", "delay": 450}
67 }
68
69
70 class UnknownChatStateException(Exception):
71 """
72 This error is raised when an unknown chat state is used.
73 """
74 pass
75
76
77 class XEP_0085(object):
78 """
79 Implementation for XEP 0085
80 """
81 params = """
82 <params>
83 <individual>
84 <category name="%(category_name)s" label="%(category_label)s">
85 <param name="%(param_name)s" label="%(param_label)s" value="true" type="bool" security="0"/>
86 </category>
87 </individual>
88 </params>
89 """ % {
90 'category_name': PARAM_KEY,
91 'category_label': _(PARAM_KEY),
92 'param_name': PARAM_NAME,
93 'param_label': _('Enable chat state notifications')
94 }
95
96 def __init__(self, host):
97 log.info(_("Chat State Notifications plugin initialization"))
98 self.host = host
99 self.map = {} # FIXME: would be better to use client instead of mapping profile to data
100
101 # parameter value is retrieved before each use
102 host.memory.updateParams(self.params)
103
104 # triggers from core
105 host.trigger.add("MessageReceived", self.messageReceivedTrigger)
106 host.trigger.add("sendMessage", self.sendMessageTrigger)
107 host.trigger.add("paramUpdateTrigger", self.paramUpdateTrigger)
108
109 # args: to_s (jid as string), profile
110 host.bridge.addMethod("chatStateComposing", ".plugin", in_sign='ss',
111 out_sign='', method=self.chatStateComposing)
112
113 # args: from (jid as string), state in CHAT_STATES, profile
114 host.bridge.addSignal("chatStateReceived", ".plugin", signature='sss')
115
116 def getHandler(self, client):
117 return XEP_0085_handler(self, client.profile)
118
119 def profileDisconnected(self, client):
120 """Eventually send a 'gone' state to all one2one contacts."""
121 profile = client.profile
122 if profile not in self.map:
123 return
124 for to_jid in self.map[profile]:
125 # FIXME: the "unavailable" presence stanza is received by to_jid
126 # before the chat state, so it will be ignored... find a way to
127 # actually defer the disconnection
128 self.map[profile][to_jid]._onEvent('gone')
129 del self.map[profile]
130
131 def updateCache(self, entity_jid, value, profile):
132 """Update the entity data of the given profile for one or all contacts.
133 Reset the chat state(s) display if the notification has been disabled.
134
135 @param entity_jid: contact's JID, or C.ENTITY_ALL to update all contacts.
136 @param value: True, False or DELETE_VALUE to delete the entity data
137 @param profile: current profile
138 """
139 if value == DELETE_VALUE:
140 self.host.memory.delEntityDatum(entity_jid, ENTITY_KEY, profile)
141 else:
142 self.host.memory.updateEntityData(entity_jid, ENTITY_KEY, value, profile_key=profile)
143 if not value or value == DELETE_VALUE:
144 # reinit chat state UI for this or these contact(s)
145 self.host.bridge.chatStateReceived(entity_jid.full(), "", profile)
146
147 def paramUpdateTrigger(self, name, value, category, type_, profile):
148 """Reset all the existing chat state entity data associated with this profile after a parameter modification.
149
150 @param name: parameter name
151 @param value: "true" to activate the notifications, or any other value to delete it
152 @param category: parameter category
153 @param type_: parameter type
154 """
155 if (category, name) == (PARAM_KEY, PARAM_NAME):
156 self.updateCache(C.ENTITY_ALL, True if C.bool(value) else DELETE_VALUE, profile=profile)
157 return False
158 return True
159
160 def messageReceivedTrigger(self, client, message, post_treat):
161 """
162 Update the entity cache when we receive a message with body.
163 Check for a chat state in the message and signal frontends.
164 """
165 profile = client.profile
166 if not self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile):
167 return True
168
169 from_jid = JID(message.getAttribute("from"))
170 if self._isMUC(from_jid, profile):
171 from_jid = from_jid.userhostJID()
172 else: # update entity data for one2one chat
173 # assert from_jid.resource # FIXME: assert doesn't work on normal message from server (e.g. server announce), because there is no resource
174 try:
175 domish.generateElementsNamed(message.elements(), name="body").next()
176 try:
177 domish.generateElementsNamed(message.elements(), name="active").next()
178 # contact enabled Chat State Notifications
179 self.updateCache(from_jid, True, profile=profile)
180 except StopIteration:
181 if message.getAttribute('type') == 'chat':
182 # contact didn't enable Chat State Notifications
183 self.updateCache(from_jid, False, profile=profile)
184 return True
185 except StopIteration:
186 pass
187
188 # send our next "composing" states to any MUC and to the contacts who enabled the feature
189 self._chatStateInit(from_jid, message.getAttribute("type"), profile)
190
191 state_list = [child.name for child in message.elements() if
192 message.getAttribute("type") in MESSAGE_TYPES
193 and child.name in CHAT_STATES
194 and child.defaultUri == NS_CHAT_STATES]
195 for state in state_list:
196 # there must be only one state according to the XEP
197 if state != 'gone' or message.getAttribute('type') != 'groupchat':
198 self.host.bridge.chatStateReceived(message.getAttribute("from"), state, profile)
199 break
200 return True
201
202 def sendMessageTrigger(self, client, mess_data, pre_xml_treatments, post_xml_treatments):
203 """
204 Eventually add the chat state to the message and initiate
205 the state machine when sending an "active" state.
206 """
207 profile = client.profile
208 def treatment(mess_data):
209 message = mess_data['xml']
210 to_jid = JID(message.getAttribute("to"))
211 if not self._checkActivation(to_jid, forceEntityData=True, profile=profile):
212 return mess_data
213 try:
214 # message with a body always mean active state
215 domish.generateElementsNamed(message.elements(), name="body").next()
216 message.addElement('active', NS_CHAT_STATES)
217 # launch the chat state machine (init the timer)
218 if self._isMUC(to_jid, profile):
219 to_jid = to_jid.userhostJID()
220 self._chatStateActive(to_jid, mess_data["type"], profile)
221 except StopIteration:
222 if "chat_state" in mess_data["extra"]:
223 state = mess_data["extra"].pop("chat_state")
224 assert state in CHAT_STATES
225 message.addElement(state, NS_CHAT_STATES)
226 return mess_data
227
228 post_xml_treatments.addCallback(treatment)
229 return True
230
231 def _isMUC(self, to_jid, profile):
232 """Tell if that JID is a MUC or not
233
234 @param to_jid (JID): full or bare JID to check
235 @param profile (str): %(doc_profile)s
236 @return: bool
237 """
238 try:
239 type_ = self.host.memory.getEntityDatum(to_jid.userhostJID(), 'type', profile)
240 if type_ == 'chatroom': # FIXME: should not use disco instead ?
241 return True
242 except (exceptions.UnknownEntityError, KeyError):
243 pass
244 return False
245
246 def _checkActivation(self, to_jid, forceEntityData, profile):
247 """
248 @param to_jid: the contact's full JID (or bare if you know it's a MUC)
249 @param forceEntityData: if set to True, a non-existing
250 entity data will be considered to be True (and initialized)
251 @param: current profile
252 @return: True if the notifications should be sent to this JID.
253 """
254 # check if the parameter is active
255 if not self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile):
256 return False
257 # check if notifications should be sent to this contact
258 if self._isMUC(to_jid, profile):
259 return True
260 # FIXME: this assertion crash when we want to send a message to an online bare jid
261 # assert to_jid.resource or not self.host.memory.isEntityAvailable(to_jid, profile) # must either have a resource, or talk to an offline contact
262 try:
263 return self.host.memory.getEntityDatum(to_jid, ENTITY_KEY, profile)
264 except (exceptions.UnknownEntityError, KeyError):
265 if forceEntityData:
266 # enable it for the first time
267 self.updateCache(to_jid, True, profile=profile)
268 return True
269 # wait for the first message before sending states
270 return False
271
272 def _chatStateInit(self, to_jid, mess_type, profile):
273 """
274 Data initialization for the chat state machine.
275
276 @param to_jid (JID): full JID for one2one, bare JID for MUC
277 @param mess_type (str): "one2one" or "groupchat"
278 @param profile (str): %(doc_profile)s
279 """
280 if mess_type is None:
281 return
282 profile_map = self.map.setdefault(profile, {})
283 if to_jid not in profile_map:
284 machine = ChatStateMachine(self.host, to_jid,
285 mess_type, profile)
286 self.map[profile][to_jid] = machine
287
288 def _chatStateActive(self, to_jid, mess_type, profile_key):
289 """
290 Launch the chat state machine on "active" state.
291
292 @param to_jid (JID): full JID for one2one, bare JID for MUC
293 @param mess_type (str): "one2one" or "groupchat"
294 @param profile (str): %(doc_profile)s
295 """
296 profile = self.host.memory.getProfileName(profile_key)
297 if profile is None:
298 raise exceptions.ProfileUnknownError
299 self._chatStateInit(to_jid, mess_type, profile)
300 self.map[profile][to_jid]._onEvent("active")
301
302 def chatStateComposing(self, to_jid_s, profile_key):
303 """Move to the "composing" state when required.
304
305 Since this method is called from the front-end, it needs to check the
306 values of the parameter "Send chat state notifications" and the entity
307 data associated to the target JID.
308
309 @param to_jid_s (str): contact full JID as a string
310 @param profile_key (str): %(doc_profile_key)s
311 """
312 # TODO: try to optimize this method which is called often
313 client = self.host.getClient(profile_key)
314 to_jid = JID(to_jid_s)
315 if self._isMUC(to_jid, client.profile):
316 to_jid = to_jid.userhostJID()
317 elif not to_jid.resource:
318 to_jid.resource = self.host.memory.getMainResource(client, to_jid)
319 if not self._checkActivation(to_jid, forceEntityData=False, profile=client.profile):
320 return
321 try:
322 self.map[client.profile][to_jid]._onEvent("composing")
323 except (KeyError, AttributeError):
324 # no message has been sent/received since the notifications
325 # have been enabled, it's better to wait for a first one
326 pass
327
328
329 class ChatStateMachine(object):
330 """
331 This class represents a chat state, between one profile and
332 one target contact. A timer is used to move from one state
333 to the other. The initialization is done through the "active"
334 state which is internally set when a message is sent. The state
335 "composing" can be set externally (through the bridge by a
336 frontend). Other states are automatically set with the timer.
337 """
338
339 def __init__(self, host, to_jid, mess_type, profile):
340 """
341 Initialization need to store the target, message type
342 and a profile for sending later messages.
343 """
344 self.host = host
345 self.to_jid = to_jid
346 self.mess_type = mess_type
347 self.profile = profile
348 self.state = None
349 self.timer = None
350
351 def _onEvent(self, state):
352 """
353 Move to the specified state, eventually send the
354 notification to the contact (the "active" state is
355 automatically sent with each message) and set the timer.
356 """
357 assert state in TRANSITIONS
358 transition = TRANSITIONS[state]
359 assert "next_state" in transition and "delay" in transition
360
361 if state != self.state and state != "active":
362 if state != 'gone' or self.mess_type != 'groupchat':
363 # send a new message without body
364 log.debug(u"sending state '{state}' to {jid}".format(state=state, jid=self.to_jid.full()))
365 client = self.host.getClient(self.profile)
366 mess_data = {
367 'from': client.jid,
368 'to': self.to_jid,
369 'uid': '',
370 'message': {},
371 'type': self.mess_type,
372 'subject': {},
373 'extra': {},
374 }
375 client.generateMessageXML(mess_data)
376 mess_data['xml'].addElement(state, NS_CHAT_STATES)
377 client.send(mess_data['xml'])
378
379 self.state = state
380 try:
381 self.timer.cancel()
382 except (internet_error.AlreadyCalled, AttributeError):
383 pass
384
385 if transition["next_state"] and transition["delay"] > 0:
386 self.timer = reactor.callLater(transition["delay"], self._onEvent, transition["next_state"])
387
388
389 class XEP_0085_handler(XMPPHandler):
390 implements(iwokkel.IDisco)
391
392 def __init__(self, plugin_parent, profile):
393 self.plugin_parent = plugin_parent
394 self.host = plugin_parent.host
395 self.profile = profile
396
397 def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
398 return [disco.DiscoFeature(NS_CHAT_STATES)]
399
400 def getDiscoItems(self, requestor, target, nodeIdentifier=''):
401 return []