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