Mercurial > libervia-backend
comparison src/plugins/plugin_xep_0085.py @ 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 | |
children | 262d9d9ad27a |
comparison
equal
deleted
inserted
replaced
635:eff8772fd472 | 636:7ea6d5a86e58 |
---|---|
1 #!/usr/bin/python | |
2 # -*- coding: utf-8 -*- | |
3 | |
4 # SAT plugin for Chat State Notifications Protocol (xep-0085) | |
5 # Copyright (C) 2009, 2010, 2011, 2012, 2013 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 import exceptions | |
21 from logging import debug, info, error | |
22 from wokkel import disco, iwokkel | |
23 from zope.interface import implements | |
24 from twisted.words.protocols.jabber.jid import JID | |
25 try: | |
26 from twisted.words.protocols.xmlstream import XMPPHandler | |
27 except ImportError: | |
28 from wokkel.subprotocols import XMPPHandler | |
29 from threading import Timer | |
30 from twisted.words.xish.domish import generateElementsNamed | |
31 | |
32 NS_XMPP_CLIENT = "jabber:client" | |
33 NS_CHAT_STATES = "http://jabber.org/protocol/chatstates" | |
34 CHAT_STATES = ["active", "inactive", "gone", "composing", "paused"] | |
35 MESSAGE_TYPES = ["chat", "groupchat"] | |
36 PARAM_KEY = "Chat State Notifications" | |
37 PARAM_NAME = "Enabled" | |
38 | |
39 PLUGIN_INFO = { | |
40 "name": "Chat State Notifications Protocol Plugin", | |
41 "import_name": "XEP-0085", | |
42 "type": "XEP", | |
43 "protocols": ["XEP-0085"], | |
44 "dependencies": [], | |
45 "main": "XEP_0085", | |
46 "handler": "yes", | |
47 "description": _("""Implementation of Chat State Notifications Protocol""") | |
48 } | |
49 | |
50 | |
51 # Describe the internal transitions that are triggered | |
52 # by a timer. Beside that, external transitions can be | |
53 # runned to target the states "active" or "composing". | |
54 # Delay is specified here in seconds. | |
55 TRANSITIONS = { | |
56 "active": {"next_state": "inactive", "delay": 120}, | |
57 "inactive": {"next_state": "gone", "delay": 480}, | |
58 "gone": {"next_state": "", "delay": 0}, | |
59 "composing": {"next_state": "paused", "delay": 30}, | |
60 "paused": {"next_state": "inactive", "delay": 450} | |
61 } | |
62 | |
63 | |
64 class UnknownChatStateException(Exception): | |
65 """ | |
66 This error is raised when an unknown chat state is used. | |
67 """ | |
68 pass | |
69 | |
70 | |
71 class XEP_0085(object): | |
72 """ | |
73 Implementation for XEP 0085 | |
74 """ | |
75 params = """ | |
76 <params> | |
77 <individual> | |
78 <category name="%(category_name)s" label="%(category_label)s"> | |
79 <param name="%(param_name)s" label="%(param_label)s" value="true" type="bool" security="0"/> | |
80 </category> | |
81 </individual> | |
82 </params> | |
83 """ % { | |
84 'category_name': PARAM_KEY, | |
85 'category_label': _(PARAM_KEY), | |
86 'param_name': PARAM_NAME, | |
87 'param_label': _('Enable chat state notifications') | |
88 } | |
89 | |
90 def __init__(self, host): | |
91 info(_("CSN plugin initialization")) | |
92 self.host = host | |
93 | |
94 # parameter value is retrieved before each use | |
95 host.memory.importParams(self.params) | |
96 | |
97 # triggers from core | |
98 host.trigger.add("MessageReceived", self.messageReceivedTrigger) | |
99 host.trigger.add("sendMessageXml", self.sendMessageXmlTrigger) | |
100 host.trigger.add("paramUpdateTrigger", self.paramUpdateTrigger) | |
101 #TODO: handle profile disconnexion (free memory in entity data) | |
102 | |
103 # args: to_s (jid as string), profile | |
104 host.bridge.addMethod("chatStateComposing", ".plugin", in_sign='ss', | |
105 out_sign='', method=self.chatStateComposing) | |
106 | |
107 # args: from (jid as string), state in CHAT_STATES, profile | |
108 host.bridge.addSignal("chatStateReceived", ".plugin", signature='sss') | |
109 | |
110 def getHandler(self, profile): | |
111 return XEP_0085_handler(self, profile) | |
112 | |
113 def updateEntityData(self, entity_jid, value, profile): | |
114 """ | |
115 Update the entity data and reset the chat state display | |
116 if the notification has been disabled. Parameter "entity_jid" | |
117 could be @ALL@ to update all entities. | |
118 """ | |
119 self.host.memory.updateEntityData(entity_jid, PARAM_KEY, value, profile) | |
120 if not value or value == "@NONE@": | |
121 # disable chat state for this or these contact(s) | |
122 self.host.bridge.chatStateReceived(unicode(entity_jid), "", profile) | |
123 | |
124 def paramUpdateTrigger(self, name, value, category, type, profile): | |
125 """ | |
126 Reset all the existing chat state entity data associated | |
127 with this profile after a parameter modification (value | |
128 different then "true" would delete the entity data). | |
129 """ | |
130 if (category, name) == (PARAM_KEY, PARAM_NAME): | |
131 self.updateEntityData("@ALL@", True if value == "true" else "@NONE@", profile) | |
132 | |
133 def messageReceivedTrigger(self, message, profile): | |
134 """ | |
135 Update the entity cache when we receive a message with body. | |
136 Check for a check state in the incoming message and broadcast signal. | |
137 """ | |
138 if not self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile): | |
139 return True | |
140 | |
141 try: | |
142 generateElementsNamed(message.children, name="body").next() | |
143 from_jid = JID(message.getAttribute("from")) | |
144 try: | |
145 generateElementsNamed(message.children, name="active").next() | |
146 # contact enabled Chat State Notifications | |
147 self.updateEntityData(from_jid, True, profile) | |
148 # init to send following "composing" state | |
149 self.__chatStateInit(from_jid, message.getAttribute("type"), profile) | |
150 except StopIteration: | |
151 # contact didn't enable Chat State Notifications | |
152 self.updateEntityData(from_jid, False, profile) | |
153 except StopIteration: | |
154 pass | |
155 | |
156 state_list = [child.name for child in message.children if | |
157 message.getAttribute("type") in MESSAGE_TYPES | |
158 and child.name in CHAT_STATES | |
159 and child.defaultUri == NS_CHAT_STATES] | |
160 for state in state_list: | |
161 # there must be only one state according to the XEP | |
162 self.host.bridge.chatStateReceived(message.getAttribute("from"), | |
163 state, profile) | |
164 break | |
165 return True | |
166 | |
167 def sendMessageXmlTrigger(self, message, mess_data, profile): | |
168 """ | |
169 Eventually add the chat state to the message and initiate | |
170 the state machine when sending an "active" state. | |
171 """ | |
172 if not self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile): | |
173 return True | |
174 | |
175 # check if notifications should be sent to this contact | |
176 contact_enabled = True | |
177 to_jid = JID(message.getAttribute("to")) | |
178 try: | |
179 contact_enabled = self.host.memory.getEntityData( | |
180 to_jid, [PARAM_KEY], profile)[PARAM_KEY] | |
181 except (exceptions.UnknownEntityError, KeyError): | |
182 # enable it for the first time | |
183 self.updateEntityData(to_jid, True, profile) | |
184 if not contact_enabled: | |
185 return True | |
186 try: | |
187 # message with a body always mean active state | |
188 generateElementsNamed(message.children, name="body").next() | |
189 message.addElement('active', NS_CHAT_STATES) | |
190 # launch the chat state machine (init the timer) | |
191 self.__chatStateActive(to_jid, mess_data["type"], profile) | |
192 except StopIteration: | |
193 if "chat_state" in mess_data["options"]: | |
194 state = mess_data["options"]["chat_state"] | |
195 assert(state in CHAT_STATES) | |
196 message.addElement(state, NS_CHAT_STATES) | |
197 return True | |
198 | |
199 def __chatStateInit(self, to_jid, mess_type, profile): | |
200 """ | |
201 Data initialization for the chat state machine. | |
202 """ | |
203 # TODO: use also the resource in map key | |
204 to_jid = to_jid.userhostJID() | |
205 if mess_type is None: | |
206 return | |
207 if not hasattr(self, "map"): | |
208 self.map = {} | |
209 profile_map = self.map.setdefault(profile, {}) | |
210 if not to_jid in profile_map: | |
211 machine = ChatStateMachine(self.host, to_jid, | |
212 mess_type, profile) | |
213 self.map[profile][to_jid] = machine | |
214 | |
215 def __chatStateActive(self, to_jid, mess_type, profile_key): | |
216 """ | |
217 Launch the chat state machine on "active" state. | |
218 """ | |
219 # TODO: use also the resource in map key | |
220 to_jid = to_jid.userhostJID() | |
221 profile = self.host.memory.getProfileName(profile_key) | |
222 if profile is None: | |
223 raise exceptions.ProfileUnknownError | |
224 self.__chatStateInit(to_jid, mess_type, profile) | |
225 self.map[profile][to_jid]._onEvent("active") | |
226 | |
227 def chatStateComposing(self, to_jid_s, profile_key): | |
228 """ | |
229 Move to the "composing" state. | |
230 """ | |
231 # TODO: use also the resource in map key | |
232 to_jid = JID(to_jid_s).userhostJID() | |
233 profile = self.host.memory.getProfileName(profile_key) | |
234 if profile is None: | |
235 raise exceptions.ProfileUnknownError | |
236 try: | |
237 self.map[profile][to_jid]._onEvent("composing") | |
238 except: | |
239 return | |
240 | |
241 | |
242 class ChatStateMachine: | |
243 """ | |
244 This class represents a chat state, between one profile and | |
245 one target contact. A timer is used to move from one state | |
246 to the other. The initialization is done through the "active" | |
247 state which is internally set when a message is sent. The state | |
248 "composing" can be set externally (through the bridge by a | |
249 frontend). Other states are automatically set with the timer. | |
250 """ | |
251 | |
252 def __init__(self, host, to_jid, mess_type, profile): | |
253 """ | |
254 Initialization need to store the target, message type | |
255 and a profile for sending later messages. | |
256 """ | |
257 self.host = host | |
258 self.to_jid = to_jid | |
259 self.mess_type = mess_type | |
260 self.profile = profile | |
261 self.state = None | |
262 self.timer = None | |
263 | |
264 def _onEvent(self, state): | |
265 """ | |
266 Move to the specified state, eventually send the | |
267 notification to the contact (the "active" state is | |
268 automatically sent with each message) and set the timer. | |
269 """ | |
270 if state != self.state and state != "active": | |
271 # send a new message without body | |
272 self.host.sendMessage(self.to_jid, | |
273 '', | |
274 '', | |
275 self.mess_type, | |
276 options={"chat_state": state}, | |
277 profile_key=self.profile) | |
278 self.state = state | |
279 if not self.timer is None: | |
280 self.timer.cancel() | |
281 | |
282 if not state in TRANSITIONS: | |
283 return | |
284 if not "next_state" in TRANSITIONS[state]: | |
285 return | |
286 if not "delay" in TRANSITIONS[state]: | |
287 return | |
288 next_state = TRANSITIONS[state]["next_state"] | |
289 delay = TRANSITIONS[state]["delay"] | |
290 if next_state == "" or delay < 0: | |
291 return | |
292 self.timer = Timer(delay, self._onEvent, [next_state]) | |
293 self.timer.start() | |
294 | |
295 | |
296 class XEP_0085_handler(XMPPHandler): | |
297 implements(iwokkel.IDisco) | |
298 | |
299 def __init__(self, plugin_parent, profile): | |
300 self.plugin_parent = plugin_parent | |
301 self.host = plugin_parent.host | |
302 self.profile = profile | |
303 | |
304 def getDiscoInfo(self, requestor, target, nodeIdentifier=''): | |
305 return [disco.DiscoFeature(NS_CHAT_STATES)] | |
306 | |
307 def getDiscoItems(self, requestor, target, nodeIdentifier=''): | |
308 return [] |