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 []