Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_xep_0085.py @ 4071:4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 11:49:51 +0200 |
parents | sat/plugins/plugin_xep_0085.py@c23cad65ae99 |
children | 0d7bb4df2343 |
comparison
equal
deleted
inserted
replaced
4070:d10748475025 | 4071:4b842c1fb686 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
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 libervia.backend.core.i18n import _ | |
21 from libervia.backend.core.constants import Const as C | |
22 from libervia.backend.core import exceptions | |
23 from libervia.backend.core.log import getLogger | |
24 | |
25 log = getLogger(__name__) | |
26 from wokkel import disco, iwokkel | |
27 from zope.interface import implementer | |
28 from twisted.words.protocols.jabber.jid import JID | |
29 | |
30 try: | |
31 from twisted.words.protocols.xmlstream import XMPPHandler | |
32 except ImportError: | |
33 from wokkel.subprotocols import XMPPHandler | |
34 from twisted.words.xish import domish | |
35 from twisted.internet import reactor | |
36 from twisted.internet import error as internet_error | |
37 | |
38 NS_XMPP_CLIENT = "jabber:client" | |
39 NS_CHAT_STATES = "http://jabber.org/protocol/chatstates" | |
40 CHAT_STATES = ["active", "inactive", "gone", "composing", "paused"] | |
41 MESSAGE_TYPES = ["chat", "groupchat"] | |
42 PARAM_KEY = "Notifications" | |
43 PARAM_NAME = "Enable chat state notifications" | |
44 ENTITY_KEY = PARAM_KEY + "_" + PARAM_NAME | |
45 DELETE_VALUE = "DELETE" | |
46 | |
47 PLUGIN_INFO = { | |
48 C.PI_NAME: "Chat State Notifications Protocol Plugin", | |
49 C.PI_IMPORT_NAME: "XEP-0085", | |
50 C.PI_TYPE: "XEP", | |
51 C.PI_PROTOCOLS: ["XEP-0085"], | |
52 C.PI_DEPENDENCIES: [], | |
53 C.PI_MAIN: "XEP_0085", | |
54 C.PI_HANDLER: "yes", | |
55 C.PI_DESCRIPTION: _("""Implementation of Chat State Notifications Protocol"""), | |
56 } | |
57 | |
58 | |
59 # Describe the internal transitions that are triggered | |
60 # by a timer. Beside that, external transitions can be | |
61 # runned to target the states "active" or "composing". | |
62 # Delay is specified here in seconds. | |
63 TRANSITIONS = { | |
64 "active": {"next_state": "inactive", "delay": 120}, | |
65 "inactive": {"next_state": "gone", "delay": 480}, | |
66 "gone": {"next_state": "", "delay": 0}, | |
67 "composing": {"next_state": "paused", "delay": 30}, | |
68 "paused": {"next_state": "inactive", "delay": 450}, | |
69 } | |
70 | |
71 | |
72 class UnknownChatStateException(Exception): | |
73 """ | |
74 This error is raised when an unknown chat state is used. | |
75 """ | |
76 | |
77 pass | |
78 | |
79 | |
80 class XEP_0085(object): | |
81 """ | |
82 Implementation for XEP 0085 | |
83 """ | |
84 | |
85 params = """ | |
86 <params> | |
87 <individual> | |
88 <category name="%(category_name)s" label="%(category_label)s"> | |
89 <param name="%(param_name)s" label="%(param_label)s" value="true" type="bool" security="0"/> | |
90 </category> | |
91 </individual> | |
92 </params> | |
93 """ % { | |
94 "category_name": PARAM_KEY, | |
95 "category_label": _(PARAM_KEY), | |
96 "param_name": PARAM_NAME, | |
97 "param_label": _("Enable chat state notifications"), | |
98 } | |
99 | |
100 def __init__(self, host): | |
101 log.info(_("Chat State Notifications plugin initialization")) | |
102 self.host = host | |
103 self.map = {} # FIXME: would be better to use client instead of mapping profile to data | |
104 | |
105 # parameter value is retrieved before each use | |
106 host.memory.update_params(self.params) | |
107 | |
108 # triggers from core | |
109 host.trigger.add("message_received", self.message_received_trigger) | |
110 host.trigger.add("sendMessage", self.send_message_trigger) | |
111 host.trigger.add("param_update_trigger", self.param_update_trigger) | |
112 | |
113 # args: to_s (jid as string), profile | |
114 host.bridge.add_method( | |
115 "chat_state_composing", | |
116 ".plugin", | |
117 in_sign="ss", | |
118 out_sign="", | |
119 method=self.chat_state_composing, | |
120 ) | |
121 | |
122 # args: from (jid as string), state in CHAT_STATES, profile | |
123 host.bridge.add_signal("chat_state_received", ".plugin", signature="sss") | |
124 | |
125 def get_handler(self, client): | |
126 return XEP_0085_handler(self, client.profile) | |
127 | |
128 def profile_disconnected(self, client): | |
129 """Eventually send a 'gone' state to all one2one contacts.""" | |
130 profile = client.profile | |
131 if profile not in self.map: | |
132 return | |
133 for to_jid in self.map[profile]: | |
134 # FIXME: the "unavailable" presence stanza is received by to_jid | |
135 # before the chat state, so it will be ignored... find a way to | |
136 # actually defer the disconnection | |
137 self.map[profile][to_jid]._onEvent("gone") | |
138 del self.map[profile] | |
139 | |
140 def update_cache(self, entity_jid, value, profile): | |
141 """Update the entity data of the given profile for one or all contacts. | |
142 Reset the chat state(s) display if the notification has been disabled. | |
143 | |
144 @param entity_jid: contact's JID, or C.ENTITY_ALL to update all contacts. | |
145 @param value: True, False or DELETE_VALUE to delete the entity data | |
146 @param profile: current profile | |
147 """ | |
148 client = self.host.get_client(profile) | |
149 if value == DELETE_VALUE: | |
150 self.host.memory.del_entity_datum(client, entity_jid, ENTITY_KEY) | |
151 else: | |
152 self.host.memory.update_entity_data( | |
153 client, entity_jid, ENTITY_KEY, value | |
154 ) | |
155 if not value or value == DELETE_VALUE: | |
156 # reinit chat state UI for this or these contact(s) | |
157 self.host.bridge.chat_state_received(entity_jid.full(), "", profile) | |
158 | |
159 def param_update_trigger(self, name, value, category, type_, profile): | |
160 """Reset all the existing chat state entity data associated with this profile after a parameter modification. | |
161 | |
162 @param name: parameter name | |
163 @param value: "true" to activate the notifications, or any other value to delete it | |
164 @param category: parameter category | |
165 @param type_: parameter type | |
166 """ | |
167 if (category, name) == (PARAM_KEY, PARAM_NAME): | |
168 self.update_cache( | |
169 C.ENTITY_ALL, True if C.bool(value) else DELETE_VALUE, profile=profile | |
170 ) | |
171 return False | |
172 return True | |
173 | |
174 def message_received_trigger(self, client, message, post_treat): | |
175 """ | |
176 Update the entity cache when we receive a message with body. | |
177 Check for a chat state in the message and signal frontends. | |
178 """ | |
179 profile = client.profile | |
180 if not self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile): | |
181 return True | |
182 | |
183 from_jid = JID(message.getAttribute("from")) | |
184 if self._is_muc(from_jid, profile): | |
185 from_jid = from_jid.userhostJID() | |
186 else: # update entity data for one2one chat | |
187 # assert from_jid.resource # FIXME: assert doesn't work on normal message from server (e.g. server announce), because there is no resource | |
188 try: | |
189 next(domish.generateElementsNamed(message.elements(), name="body")) | |
190 try: | |
191 next(domish.generateElementsNamed(message.elements(), name="active")) | |
192 # contact enabled Chat State Notifications | |
193 self.update_cache(from_jid, True, profile=profile) | |
194 except StopIteration: | |
195 if message.getAttribute("type") == "chat": | |
196 # contact didn't enable Chat State Notifications | |
197 self.update_cache(from_jid, False, profile=profile) | |
198 return True | |
199 except StopIteration: | |
200 pass | |
201 | |
202 # send our next "composing" states to any MUC and to the contacts who enabled the feature | |
203 self._chat_state_init(from_jid, message.getAttribute("type"), profile) | |
204 | |
205 state_list = [ | |
206 child.name | |
207 for child in message.elements() | |
208 if message.getAttribute("type") in MESSAGE_TYPES | |
209 and child.name in CHAT_STATES | |
210 and child.defaultUri == NS_CHAT_STATES | |
211 ] | |
212 for state in state_list: | |
213 # there must be only one state according to the XEP | |
214 if state != "gone" or message.getAttribute("type") != "groupchat": | |
215 self.host.bridge.chat_state_received( | |
216 message.getAttribute("from"), state, profile | |
217 ) | |
218 break | |
219 return True | |
220 | |
221 def send_message_trigger( | |
222 self, client, mess_data, pre_xml_treatments, post_xml_treatments | |
223 ): | |
224 """ | |
225 Eventually add the chat state to the message and initiate | |
226 the state machine when sending an "active" state. | |
227 """ | |
228 profile = client.profile | |
229 | |
230 def treatment(mess_data): | |
231 message = mess_data["xml"] | |
232 to_jid = JID(message.getAttribute("to")) | |
233 if not self._check_activation(to_jid, forceEntityData=True, profile=profile): | |
234 return mess_data | |
235 try: | |
236 # message with a body always mean active state | |
237 next(domish.generateElementsNamed(message.elements(), name="body")) | |
238 message.addElement("active", NS_CHAT_STATES) | |
239 # launch the chat state machine (init the timer) | |
240 if self._is_muc(to_jid, profile): | |
241 to_jid = to_jid.userhostJID() | |
242 self._chat_state_active(to_jid, mess_data["type"], profile) | |
243 except StopIteration: | |
244 if "chat_state" in mess_data["extra"]: | |
245 state = mess_data["extra"].pop("chat_state") | |
246 assert state in CHAT_STATES | |
247 message.addElement(state, NS_CHAT_STATES) | |
248 return mess_data | |
249 | |
250 post_xml_treatments.addCallback(treatment) | |
251 return True | |
252 | |
253 def _is_muc(self, to_jid, profile): | |
254 """Tell if that JID is a MUC or not | |
255 | |
256 @param to_jid (JID): full or bare JID to check | |
257 @param profile (str): %(doc_profile)s | |
258 @return: bool | |
259 """ | |
260 client = self.host.get_client(profile) | |
261 try: | |
262 type_ = self.host.memory.get_entity_datum( | |
263 client, to_jid.userhostJID(), C.ENTITY_TYPE) | |
264 if type_ == C.ENTITY_TYPE_MUC: | |
265 return True | |
266 except (exceptions.UnknownEntityError, KeyError): | |
267 pass | |
268 return False | |
269 | |
270 def _check_activation(self, to_jid, forceEntityData, profile): | |
271 """ | |
272 @param to_jid: the contact's full JID (or bare if you know it's a MUC) | |
273 @param forceEntityData: if set to True, a non-existing | |
274 entity data will be considered to be True (and initialized) | |
275 @param: current profile | |
276 @return: True if the notifications should be sent to this JID. | |
277 """ | |
278 client = self.host.get_client(profile) | |
279 # check if the parameter is active | |
280 if not self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile): | |
281 return False | |
282 # check if notifications should be sent to this contact | |
283 if self._is_muc(to_jid, profile): | |
284 return True | |
285 # FIXME: this assertion crash when we want to send a message to an online bare jid | |
286 # 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 | |
287 try: | |
288 return self.host.memory.get_entity_datum(client, to_jid, ENTITY_KEY) | |
289 except (exceptions.UnknownEntityError, KeyError): | |
290 if forceEntityData: | |
291 # enable it for the first time | |
292 self.update_cache(to_jid, True, profile=profile) | |
293 return True | |
294 # wait for the first message before sending states | |
295 return False | |
296 | |
297 def _chat_state_init(self, to_jid, mess_type, profile): | |
298 """ | |
299 Data initialization for the chat state machine. | |
300 | |
301 @param to_jid (JID): full JID for one2one, bare JID for MUC | |
302 @param mess_type (str): "one2one" or "groupchat" | |
303 @param profile (str): %(doc_profile)s | |
304 """ | |
305 if mess_type is None: | |
306 return | |
307 profile_map = self.map.setdefault(profile, {}) | |
308 if to_jid not in profile_map: | |
309 machine = ChatStateMachine(self.host, to_jid, mess_type, profile) | |
310 self.map[profile][to_jid] = machine | |
311 | |
312 def _chat_state_active(self, to_jid, mess_type, profile_key): | |
313 """ | |
314 Launch the chat state machine on "active" state. | |
315 | |
316 @param to_jid (JID): full JID for one2one, bare JID for MUC | |
317 @param mess_type (str): "one2one" or "groupchat" | |
318 @param profile (str): %(doc_profile)s | |
319 """ | |
320 profile = self.host.memory.get_profile_name(profile_key) | |
321 if profile is None: | |
322 raise exceptions.ProfileUnknownError | |
323 self._chat_state_init(to_jid, mess_type, profile) | |
324 self.map[profile][to_jid]._onEvent("active") | |
325 | |
326 def chat_state_composing(self, to_jid_s, profile_key): | |
327 """Move to the "composing" state when required. | |
328 | |
329 Since this method is called from the front-end, it needs to check the | |
330 values of the parameter "Send chat state notifications" and the entity | |
331 data associated to the target JID. | |
332 | |
333 @param to_jid_s (str): contact full JID as a string | |
334 @param profile_key (str): %(doc_profile_key)s | |
335 """ | |
336 # TODO: try to optimize this method which is called often | |
337 client = self.host.get_client(profile_key) | |
338 to_jid = JID(to_jid_s) | |
339 if self._is_muc(to_jid, client.profile): | |
340 to_jid = to_jid.userhostJID() | |
341 elif not to_jid.resource: | |
342 to_jid.resource = self.host.memory.main_resource_get(client, to_jid) | |
343 if not self._check_activation( | |
344 to_jid, forceEntityData=False, profile=client.profile | |
345 ): | |
346 return | |
347 try: | |
348 self.map[client.profile][to_jid]._onEvent("composing") | |
349 except (KeyError, AttributeError): | |
350 # no message has been sent/received since the notifications | |
351 # have been enabled, it's better to wait for a first one | |
352 pass | |
353 | |
354 | |
355 class ChatStateMachine(object): | |
356 """ | |
357 This class represents a chat state, between one profile and | |
358 one target contact. A timer is used to move from one state | |
359 to the other. The initialization is done through the "active" | |
360 state which is internally set when a message is sent. The state | |
361 "composing" can be set externally (through the bridge by a | |
362 frontend). Other states are automatically set with the timer. | |
363 """ | |
364 | |
365 def __init__(self, host, to_jid, mess_type, profile): | |
366 """ | |
367 Initialization need to store the target, message type | |
368 and a profile for sending later messages. | |
369 """ | |
370 self.host = host | |
371 self.to_jid = to_jid | |
372 self.mess_type = mess_type | |
373 self.profile = profile | |
374 self.state = None | |
375 self.timer = None | |
376 | |
377 def _onEvent(self, state): | |
378 """ | |
379 Move to the specified state, eventually send the | |
380 notification to the contact (the "active" state is | |
381 automatically sent with each message) and set the timer. | |
382 """ | |
383 assert state in TRANSITIONS | |
384 transition = TRANSITIONS[state] | |
385 assert "next_state" in transition and "delay" in transition | |
386 | |
387 if state != self.state and state != "active": | |
388 if state != "gone" or self.mess_type != "groupchat": | |
389 # send a new message without body | |
390 log.debug( | |
391 "sending state '{state}' to {jid}".format( | |
392 state=state, jid=self.to_jid.full() | |
393 ) | |
394 ) | |
395 client = self.host.get_client(self.profile) | |
396 mess_data = { | |
397 "from": client.jid, | |
398 "to": self.to_jid, | |
399 "uid": "", | |
400 "message": {}, | |
401 "type": self.mess_type, | |
402 "subject": {}, | |
403 "extra": {}, | |
404 } | |
405 client.generate_message_xml(mess_data) | |
406 mess_data["xml"].addElement(state, NS_CHAT_STATES) | |
407 client.send(mess_data["xml"]) | |
408 | |
409 self.state = state | |
410 try: | |
411 self.timer.cancel() | |
412 except (internet_error.AlreadyCalled, AttributeError): | |
413 pass | |
414 | |
415 if transition["next_state"] and transition["delay"] > 0: | |
416 self.timer = reactor.callLater( | |
417 transition["delay"], self._onEvent, transition["next_state"] | |
418 ) | |
419 | |
420 | |
421 @implementer(iwokkel.IDisco) | |
422 class XEP_0085_handler(XMPPHandler): | |
423 | |
424 def __init__(self, plugin_parent, profile): | |
425 self.plugin_parent = plugin_parent | |
426 self.host = plugin_parent.host | |
427 self.profile = profile | |
428 | |
429 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | |
430 return [disco.DiscoFeature(NS_CHAT_STATES)] | |
431 | |
432 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | |
433 return [] |