Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_xep_0045.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_0045.py@c23cad65ae99 |
children | 2ea567afc0cf |
comparison
equal
deleted
inserted
replaced
4070:d10748475025 | 4071:4b842c1fb686 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 | |
4 # SAT plugin for managing xep-0045 | |
5 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.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 import time | |
21 from typing import Optional | |
22 import uuid | |
23 | |
24 from twisted.internet import defer | |
25 from twisted.python import failure | |
26 from twisted.words.protocols.jabber import jid | |
27 from twisted.words.protocols.jabber import error as xmpp_error | |
28 from wokkel import disco, iwokkel, muc | |
29 from wokkel import rsm | |
30 from wokkel import mam | |
31 from zope.interface import implementer | |
32 | |
33 from libervia.backend.core import exceptions | |
34 from libervia.backend.core.core_types import SatXMPPEntity | |
35 from libervia.backend.core.constants import Const as C | |
36 from libervia.backend.core.i18n import D_, _ | |
37 from libervia.backend.core.log import getLogger | |
38 from libervia.backend.memory import memory | |
39 from libervia.backend.tools import xml_tools, utils | |
40 | |
41 | |
42 log = getLogger(__name__) | |
43 | |
44 | |
45 PLUGIN_INFO = { | |
46 C.PI_NAME: "XEP-0045 Plugin", | |
47 C.PI_IMPORT_NAME: "XEP-0045", | |
48 C.PI_TYPE: "XEP", | |
49 C.PI_PROTOCOLS: ["XEP-0045"], | |
50 C.PI_DEPENDENCIES: ["XEP-0359"], | |
51 C.PI_RECOMMENDATIONS: [C.TEXT_CMDS, "XEP-0313"], | |
52 C.PI_MAIN: "XEP_0045", | |
53 C.PI_HANDLER: "yes", | |
54 C.PI_DESCRIPTION: _("""Implementation of Multi-User Chat""") | |
55 } | |
56 | |
57 NS_MUC = 'http://jabber.org/protocol/muc' | |
58 AFFILIATIONS = ('owner', 'admin', 'member', 'none', 'outcast') | |
59 ROOM_USER_JOINED = 'ROOM_USER_JOINED' | |
60 ROOM_USER_LEFT = 'ROOM_USER_LEFT' | |
61 OCCUPANT_KEYS = ('nick', 'entity', 'affiliation', 'role') | |
62 ROOM_STATE_OCCUPANTS = "occupants" | |
63 ROOM_STATE_SELF_PRESENCE = "self-presence" | |
64 ROOM_STATE_LIVE = "live" | |
65 ROOM_STATES = (ROOM_STATE_OCCUPANTS, ROOM_STATE_SELF_PRESENCE, ROOM_STATE_LIVE) | |
66 HISTORY_LEGACY = "legacy" | |
67 HISTORY_MAM = "mam" | |
68 | |
69 | |
70 CONFIG_SECTION = 'plugin muc' | |
71 | |
72 default_conf = {"default_muc": 'sat@chat.jabberfr.org'} | |
73 | |
74 | |
75 class AlreadyJoined(exceptions.ConflictError): | |
76 | |
77 def __init__(self, room): | |
78 super(AlreadyJoined, self).__init__() | |
79 self.room = room | |
80 | |
81 | |
82 class XEP_0045(object): | |
83 # TODO: handle invitations | |
84 # FIXME: this plugin need a good cleaning, join method is messy | |
85 | |
86 def __init__(self, host): | |
87 log.info(_("Plugin XEP_0045 initialization")) | |
88 self.host = host | |
89 self._sessions = memory.Sessions() | |
90 # return same arguments as muc_room_joined + a boolean set to True is the room was | |
91 # already joined (first argument) | |
92 host.bridge.add_method( | |
93 "muc_join", ".plugin", in_sign='ssa{ss}s', out_sign='(bsa{sa{ss}}ssass)', | |
94 method=self._join, async_=True) | |
95 host.bridge.add_method( | |
96 "muc_nick", ".plugin", in_sign='sss', out_sign='', method=self._nick) | |
97 host.bridge.add_method( | |
98 "muc_nick_get", ".plugin", in_sign='ss', out_sign='s', method=self._get_room_nick) | |
99 host.bridge.add_method( | |
100 "muc_leave", ".plugin", in_sign='ss', out_sign='', method=self._leave, | |
101 async_=True) | |
102 host.bridge.add_method( | |
103 "muc_occupants_get", ".plugin", in_sign='ss', out_sign='a{sa{ss}}', | |
104 method=self._get_room_occupants) | |
105 host.bridge.add_method( | |
106 "muc_subject", ".plugin", in_sign='sss', out_sign='', method=self._subject) | |
107 host.bridge.add_method( | |
108 "muc_get_rooms_joined", ".plugin", in_sign='s', out_sign='a(sa{sa{ss}}ssas)', | |
109 method=self._get_rooms_joined) | |
110 host.bridge.add_method( | |
111 "muc_get_unique_room_name", ".plugin", in_sign='ss', out_sign='s', | |
112 method=self._get_unique_name) | |
113 host.bridge.add_method( | |
114 "muc_configure_room", ".plugin", in_sign='ss', out_sign='s', | |
115 method=self._configure_room, async_=True) | |
116 host.bridge.add_method( | |
117 "muc_get_default_service", ".plugin", in_sign='', out_sign='s', | |
118 method=self.get_default_muc) | |
119 host.bridge.add_method( | |
120 "muc_get_service", ".plugin", in_sign='ss', out_sign='s', | |
121 method=self._get_muc_service, async_=True) | |
122 # called when a room will be joined but must be locked until join is received | |
123 # (room is prepared, history is getting retrieved) | |
124 # args: room_jid, profile | |
125 host.bridge.add_signal( | |
126 "muc_room_prepare_join", ".plugin", signature='ss') | |
127 # args: room_jid, occupants, user_nick, subject, profile | |
128 host.bridge.add_signal( | |
129 "muc_room_joined", ".plugin", signature='sa{sa{ss}}ssass') | |
130 # args: room_jid, profile | |
131 host.bridge.add_signal( | |
132 "muc_room_left", ".plugin", signature='ss') | |
133 # args: room_jid, old_nick, new_nick, profile | |
134 host.bridge.add_signal( | |
135 "muc_room_user_changed_nick", ".plugin", signature='ssss') | |
136 # args: room_jid, subject, profile | |
137 host.bridge.add_signal( | |
138 "muc_room_new_subject", ".plugin", signature='sss') | |
139 self.__submit_conf_id = host.register_callback( | |
140 self._submit_configuration, with_data=True) | |
141 self._room_join_id = host.register_callback(self._ui_room_join_cb, with_data=True) | |
142 host.import_menu( | |
143 (D_("MUC"), D_("configure")), self._configure_room_menu, security_limit=0, | |
144 help_string=D_("Configure Multi-User Chat room"), type_=C.MENU_ROOM) | |
145 try: | |
146 self.text_cmds = self.host.plugins[C.TEXT_CMDS] | |
147 except KeyError: | |
148 log.info(_("Text commands not available")) | |
149 else: | |
150 self.text_cmds.register_text_commands(self) | |
151 self.text_cmds.add_who_is_cb(self._whois, 100) | |
152 | |
153 self._mam = self.host.plugins.get("XEP-0313") | |
154 self._si = self.host.plugins["XEP-0359"] | |
155 | |
156 host.trigger.add("presence_available", self.presence_trigger) | |
157 host.trigger.add("presence_received", self.presence_received_trigger) | |
158 host.trigger.add("message_received", self.message_received_trigger, priority=1000000) | |
159 host.trigger.add("message_parse", self._message_parse_trigger) | |
160 | |
161 async def profile_connected(self, client): | |
162 client.muc_service = await self.get_muc_service(client) | |
163 | |
164 def _message_parse_trigger(self, client, message_elt, data): | |
165 """Add stanza-id from the room if present""" | |
166 if message_elt.getAttribute("type") != C.MESS_TYPE_GROUPCHAT: | |
167 return True | |
168 | |
169 # stanza_id will not be filled by parse_message because the emitter | |
170 # is the room and not our server, so we have to parse it here | |
171 room_jid = data["from"].userhostJID() | |
172 stanza_id = self._si.get_stanza_id(message_elt, room_jid) | |
173 if stanza_id: | |
174 data["extra"]["stanza_id"] = stanza_id | |
175 | |
176 def message_received_trigger(self, client, message_elt, post_treat): | |
177 if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT: | |
178 if message_elt.subject or message_elt.delay: | |
179 return False | |
180 from_jid = jid.JID(message_elt['from']) | |
181 room_jid = from_jid.userhostJID() | |
182 if room_jid in client._muc_client.joined_rooms: | |
183 room = client._muc_client.joined_rooms[room_jid] | |
184 if room.state != ROOM_STATE_LIVE: | |
185 if getattr(room, "_history_type", HISTORY_LEGACY) == HISTORY_LEGACY: | |
186 # With MAM history, order is different, and we can get live | |
187 # messages before history is complete, so this is not a warning | |
188 # but an expected case. | |
189 # On the other hand, with legacy history, it's not normal. | |
190 log.warning(_( | |
191 "Received non delayed message in a room before its " | |
192 "initialisation: state={state}, msg={msg}").format( | |
193 state=room.state, | |
194 msg=message_elt.toXml())) | |
195 room._cache.append(message_elt) | |
196 return False | |
197 else: | |
198 log.warning("Received groupchat message for a room which has not been " | |
199 "joined, ignoring it: {}".format(message_elt.toXml())) | |
200 return False | |
201 return True | |
202 | |
203 def get_room(self, client: SatXMPPEntity, room_jid: jid.JID) -> muc.Room: | |
204 """Retrieve Room instance from its jid | |
205 | |
206 @param room_jid: jid of the room | |
207 @raise exceptions.NotFound: the room has not been joined | |
208 """ | |
209 try: | |
210 return client._muc_client.joined_rooms[room_jid] | |
211 except KeyError: | |
212 raise exceptions.NotFound(_("This room has not been joined")) | |
213 | |
214 def check_room_joined(self, client, room_jid): | |
215 """Check that given room has been joined in current session | |
216 | |
217 @param room_jid (JID): room JID | |
218 """ | |
219 if room_jid not in client._muc_client.joined_rooms: | |
220 raise exceptions.NotFound(_("This room has not been joined")) | |
221 | |
222 def is_joined_room(self, client: SatXMPPEntity, room_jid: jid.JID) -> bool: | |
223 """Tell if a jid is a known and joined room | |
224 | |
225 @room_jid: jid of the room | |
226 """ | |
227 try: | |
228 self.check_room_joined(client, room_jid) | |
229 except exceptions.NotFound: | |
230 return False | |
231 else: | |
232 return True | |
233 | |
234 def is_room(self, client, entity_jid): | |
235 """Tell if a jid is a joined MUC | |
236 | |
237 similar to is_joined_room but returns a boolean | |
238 @param entity_jid(jid.JID): full or bare jid of the entity check | |
239 @return (bool): True if the bare jid of the entity is a room jid | |
240 """ | |
241 try: | |
242 self.check_room_joined(client, entity_jid.userhostJID()) | |
243 except exceptions.NotFound: | |
244 return False | |
245 else: | |
246 return True | |
247 | |
248 def get_bare_or_full(self, client, peer_jid): | |
249 """use full jid if peer_jid is an occupant of a room, bare jid else | |
250 | |
251 @param peer_jid(jid.JID): entity to test | |
252 @return (jid.JID): bare or full jid | |
253 """ | |
254 if peer_jid.resource: | |
255 if not self.is_room(client, peer_jid): | |
256 return peer_jid.userhostJID() | |
257 return peer_jid | |
258 | |
259 def _get_room_joined_args(self, room, profile): | |
260 return [ | |
261 room.roomJID.userhost(), | |
262 XEP_0045._get_occupants(room), | |
263 room.nick, | |
264 room.subject, | |
265 [s.name for s in room.statuses], | |
266 profile | |
267 ] | |
268 | |
269 def _ui_room_join_cb(self, data, profile): | |
270 room_jid = jid.JID(data['index']) | |
271 client = self.host.get_client(profile) | |
272 self.join(client, room_jid) | |
273 return {} | |
274 | |
275 def _password_ui_cb(self, data, client, room_jid, nick): | |
276 """Called when the user has given room password (or cancelled)""" | |
277 if C.bool(data.get(C.XMLUI_DATA_CANCELLED, "false")): | |
278 log.info("room join for {} is cancelled".format(room_jid.userhost())) | |
279 raise failure.Failure(exceptions.CancelError(D_("Room joining cancelled by user"))) | |
280 password = data[xml_tools.form_escape('password')] | |
281 return client._muc_client.join(room_jid, nick, password).addCallbacks(self._join_cb, self._join_eb, (client, room_jid, nick), errbackArgs=(client, room_jid, nick, password)) | |
282 | |
283 def _show_list_ui(self, items, client, service): | |
284 xmlui = xml_tools.XMLUI(title=D_('Rooms in {}'.format(service.full()))) | |
285 adv_list = xmlui.change_container('advanced_list', columns=1, selectable='single', callback_id=self._room_join_id) | |
286 items = sorted(items, key=lambda i: i.name.lower()) | |
287 for item in items: | |
288 adv_list.set_row_index(item.entity.full()) | |
289 xmlui.addText(item.name) | |
290 adv_list.end() | |
291 self.host.action_new({'xmlui': xmlui.toXml()}, profile=client.profile) | |
292 | |
293 def _join_cb(self, room, client, room_jid, nick): | |
294 """Called when the user is in the requested room""" | |
295 if room.locked: | |
296 # FIXME: the current behaviour is to create an instant room | |
297 # and send the signal only when the room is unlocked | |
298 # a proper configuration management should be done | |
299 log.debug(_("room locked !")) | |
300 d = client._muc_client.configure(room.roomJID, {}) | |
301 d.addErrback(self.host.log_errback, | |
302 msg=_('Error while configuring the room: {failure_}')) | |
303 return room.fully_joined | |
304 | |
305 def _join_eb(self, failure_, client, room_jid, nick, password): | |
306 """Called when something is going wrong when joining the room""" | |
307 try: | |
308 condition = failure_.value.condition | |
309 except AttributeError: | |
310 msg_suffix = f': {failure_}' | |
311 else: | |
312 if condition == 'conflict': | |
313 # we have a nickname conflict, we try again with "_" suffixed to current nickname | |
314 nick += '_' | |
315 return client._muc_client.join(room_jid, nick, password).addCallbacks(self._join_cb, self._join_eb, (client, room_jid, nick), errbackArgs=(client, room_jid, nick, password)) | |
316 elif condition == 'not-allowed': | |
317 # room is restricted, we need a password | |
318 password_ui = xml_tools.XMLUI("form", title=D_('Room {} is restricted').format(room_jid.userhost()), submit_id='') | |
319 password_ui.addText(D_("This room is restricted, please enter the password")) | |
320 password_ui.addPassword('password') | |
321 d = xml_tools.defer_xmlui(self.host, password_ui, profile=client.profile) | |
322 d.addCallback(self._password_ui_cb, client, room_jid, nick) | |
323 return d | |
324 | |
325 msg_suffix = ' with condition "{}"'.format(failure_.value.condition) | |
326 | |
327 mess = D_("Error while joining the room {room}{suffix}".format( | |
328 room = room_jid.userhost(), suffix = msg_suffix)) | |
329 log.warning(mess) | |
330 xmlui = xml_tools.note(mess, D_("Group chat error"), level=C.XMLUI_DATA_LVL_ERROR) | |
331 self.host.action_new({'xmlui': xmlui.toXml()}, profile=client.profile) | |
332 | |
333 @staticmethod | |
334 def _get_occupants(room): | |
335 """Get occupants of a room in a form suitable for bridge""" | |
336 return {u.nick: {k:str(getattr(u,k) or '') for k in OCCUPANT_KEYS} for u in list(room.roster.values())} | |
337 | |
338 def _get_room_occupants(self, room_jid_s, profile_key): | |
339 client = self.host.get_client(profile_key) | |
340 room_jid = jid.JID(room_jid_s) | |
341 return self.get_room_occupants(client, room_jid) | |
342 | |
343 def get_room_occupants(self, client, room_jid): | |
344 room = self.get_room(client, room_jid) | |
345 return self._get_occupants(room) | |
346 | |
347 def _get_rooms_joined(self, profile_key=C.PROF_KEY_NONE): | |
348 client = self.host.get_client(profile_key) | |
349 return self.get_rooms_joined(client) | |
350 | |
351 def get_rooms_joined(self, client): | |
352 """Return rooms where user is""" | |
353 result = [] | |
354 for room in list(client._muc_client.joined_rooms.values()): | |
355 if room.state == ROOM_STATE_LIVE: | |
356 result.append( | |
357 (room.roomJID.userhost(), | |
358 self._get_occupants(room), | |
359 room.nick, | |
360 room.subject, | |
361 [s.name for s in room.statuses], | |
362 ) | |
363 ) | |
364 return result | |
365 | |
366 def _get_room_nick(self, room_jid_s, profile_key=C.PROF_KEY_NONE): | |
367 client = self.host.get_client(profile_key) | |
368 return self.get_room_nick(client, jid.JID(room_jid_s)) | |
369 | |
370 def get_room_nick(self, client, room_jid): | |
371 """return nick used in room by user | |
372 | |
373 @param room_jid (jid.JID): JID of the room | |
374 @profile_key: profile | |
375 @return: nick or empty string in case of error | |
376 @raise exceptions.Notfound: use has not joined the room | |
377 """ | |
378 self.check_room_joined(client, room_jid) | |
379 return client._muc_client.joined_rooms[room_jid].nick | |
380 | |
381 def _configure_room(self, room_jid_s, profile_key=C.PROF_KEY_NONE): | |
382 client = self.host.get_client(profile_key) | |
383 d = self.configure_room(client, jid.JID(room_jid_s)) | |
384 d.addCallback(lambda xmlui: xmlui.toXml()) | |
385 return d | |
386 | |
387 def _configure_room_menu(self, menu_data, profile): | |
388 """Return room configuration form | |
389 | |
390 @param menu_data: %(menu_data)s | |
391 @param profile: %(doc_profile)s | |
392 """ | |
393 client = self.host.get_client(profile) | |
394 try: | |
395 room_jid = jid.JID(menu_data['room_jid']) | |
396 except KeyError: | |
397 log.error(_("room_jid key is not present !")) | |
398 return defer.fail(exceptions.DataError) | |
399 | |
400 def xmlui_received(xmlui): | |
401 if not xmlui: | |
402 msg = D_("No configuration available for this room") | |
403 return {"xmlui": xml_tools.note(msg).toXml()} | |
404 return {"xmlui": xmlui.toXml()} | |
405 return self.configure_room(client, room_jid).addCallback(xmlui_received) | |
406 | |
407 def configure_room(self, client, room_jid): | |
408 """return the room configuration form | |
409 | |
410 @param room: jid of the room to configure | |
411 @return: configuration form as XMLUI | |
412 """ | |
413 self.check_room_joined(client, room_jid) | |
414 | |
415 def config_2_xmlui(result): | |
416 if not result: | |
417 return "" | |
418 session_id, session_data = self._sessions.new_session(profile=client.profile) | |
419 session_data["room_jid"] = room_jid | |
420 xmlui = xml_tools.data_form_2_xmlui(result, submit_id=self.__submit_conf_id) | |
421 xmlui.session_id = session_id | |
422 return xmlui | |
423 | |
424 d = client._muc_client.getConfiguration(room_jid) | |
425 d.addCallback(config_2_xmlui) | |
426 return d | |
427 | |
428 def _submit_configuration(self, raw_data, profile): | |
429 cancelled = C.bool(raw_data.get("cancelled", C.BOOL_FALSE)) | |
430 if cancelled: | |
431 return defer.succeed({}) | |
432 client = self.host.get_client(profile) | |
433 try: | |
434 session_data = self._sessions.profile_get(raw_data["session_id"], profile) | |
435 except KeyError: | |
436 log.warning(D_("Session ID doesn't exist, session has probably expired.")) | |
437 _dialog = xml_tools.XMLUI('popup', title=D_('Room configuration failed')) | |
438 _dialog.addText(D_("Session ID doesn't exist, session has probably expired.")) | |
439 return defer.succeed({'xmlui': _dialog.toXml()}) | |
440 | |
441 data = xml_tools.xmlui_result_2_data_form_result(raw_data) | |
442 d = client._muc_client.configure(session_data['room_jid'], data) | |
443 _dialog = xml_tools.XMLUI('popup', title=D_('Room configuration succeed')) | |
444 _dialog.addText(D_("The new settings have been saved.")) | |
445 d.addCallback(lambda ignore: {'xmlui': _dialog.toXml()}) | |
446 del self._sessions[raw_data["session_id"]] | |
447 return d | |
448 | |
449 def is_nick_in_room(self, client, room_jid, nick): | |
450 """Tell if a nick is currently present in a room""" | |
451 self.check_room_joined(client, room_jid) | |
452 return client._muc_client.joined_rooms[room_jid].inRoster(muc.User(nick)) | |
453 | |
454 def _get_muc_service(self, jid_=None, profile=C.PROF_KEY_NONE): | |
455 client = self.host.get_client(profile) | |
456 d = defer.ensureDeferred(self.get_muc_service(client, jid_ or None)) | |
457 d.addCallback(lambda service_jid: service_jid.full() if service_jid is not None else '') | |
458 return d | |
459 | |
460 async def get_muc_service( | |
461 self, | |
462 client: SatXMPPEntity, | |
463 jid_: Optional[jid.JID] = None) -> Optional[jid.JID]: | |
464 """Return first found MUC service of an entity | |
465 | |
466 @param jid_: entity which may have a MUC service, or None for our own server | |
467 @return: found service jid or None | |
468 """ | |
469 if jid_ is None: | |
470 try: | |
471 muc_service = client.muc_service | |
472 except AttributeError: | |
473 pass | |
474 else: | |
475 # we have a cached value, we return it | |
476 return muc_service | |
477 services = await self.host.find_service_entities(client, "conference", "text", jid_) | |
478 for service in services: | |
479 if ".irc." not in service.userhost(): | |
480 # FIXME: | |
481 # This ugly hack is here to avoid an issue with openfire: the IRC gateway | |
482 # use "conference/text" identity (instead of "conference/irc") | |
483 muc_service = service | |
484 break | |
485 else: | |
486 muc_service = None | |
487 return muc_service | |
488 | |
489 def _get_unique_name(self, muc_service="", profile_key=C.PROF_KEY_NONE): | |
490 client = self.host.get_client(profile_key) | |
491 return self.get_unique_name(client, muc_service or None).full() | |
492 | |
493 def get_unique_name(self, client, muc_service=None): | |
494 """Return unique name for a room, avoiding collision | |
495 | |
496 @param muc_service (jid.JID) : leave empty string to use the default service | |
497 @return: jid.JID (unique room bare JID) | |
498 """ | |
499 # TODO: we should use #RFC-0045 10.1.4 when available here | |
500 room_name = str(uuid.uuid4()) | |
501 if muc_service is None: | |
502 try: | |
503 muc_service = client.muc_service | |
504 except AttributeError: | |
505 raise exceptions.NotReady("Main server MUC service has not been checked yet") | |
506 if muc_service is None: | |
507 log.warning(_("No MUC service found on main server")) | |
508 raise exceptions.FeatureNotFound | |
509 | |
510 muc_service = muc_service.userhost() | |
511 return jid.JID("{}@{}".format(room_name, muc_service)) | |
512 | |
513 def get_default_muc(self): | |
514 """Return the default MUC. | |
515 | |
516 @return: unicode | |
517 """ | |
518 return self.host.memory.config_get(CONFIG_SECTION, 'default_muc', default_conf['default_muc']) | |
519 | |
520 def _join_eb(self, failure_, client): | |
521 failure_.trap(AlreadyJoined) | |
522 room = failure_.value.room | |
523 return [True] + self._get_room_joined_args(room, client.profile) | |
524 | |
525 def _join(self, room_jid_s, nick, options, profile_key=C.PROF_KEY_NONE): | |
526 """join method used by bridge | |
527 | |
528 @return (tuple): already_joined boolean + room joined arguments (see [_get_room_joined_args]) | |
529 """ | |
530 client = self.host.get_client(profile_key) | |
531 if room_jid_s: | |
532 muc_service = client.muc_service | |
533 try: | |
534 room_jid = jid.JID(room_jid_s) | |
535 except (RuntimeError, jid.InvalidFormat, AttributeError): | |
536 return defer.fail(jid.InvalidFormat(_("Invalid room identifier: {room_id}'. Please give a room short or full identifier like 'room' or 'room@{muc_service}'.").format( | |
537 room_id=room_jid_s, | |
538 muc_service=str(muc_service)))) | |
539 if not room_jid.user: | |
540 room_jid.user, room_jid.host = room_jid.host, muc_service | |
541 else: | |
542 room_jid = self.get_unique_name(profile_key=client.profile) | |
543 # TODO: error management + signal in bridge | |
544 d = self.join(client, room_jid, nick, options or None) | |
545 d.addCallback(lambda room: [False] + self._get_room_joined_args(room, client.profile)) | |
546 d.addErrback(self._join_eb, client) | |
547 return d | |
548 | |
549 async def join( | |
550 self, | |
551 client: SatXMPPEntity, | |
552 room_jid: jid.JID, | |
553 nick: Optional[str] = None, | |
554 options: Optional[dict] = None | |
555 ) -> Optional[muc.Room]: | |
556 if not nick: | |
557 nick = client.jid.user | |
558 if options is None: | |
559 options = {} | |
560 if room_jid in client._muc_client.joined_rooms: | |
561 room = client._muc_client.joined_rooms[room_jid] | |
562 log.info(_('{profile} is already in room {room_jid}').format( | |
563 profile=client.profile, room_jid = room_jid.userhost())) | |
564 raise AlreadyJoined(room) | |
565 log.info(_("[{profile}] is joining room {room} with nick {nick}").format( | |
566 profile=client.profile, room=room_jid.userhost(), nick=nick)) | |
567 self.host.bridge.muc_room_prepare_join(room_jid.userhost(), client.profile) | |
568 | |
569 password = options.get("password") | |
570 | |
571 try: | |
572 room = await client._muc_client.join(room_jid, nick, password) | |
573 except Exception as e: | |
574 room = await utils.as_deferred( | |
575 self._join_eb(failure.Failure(e), client, room_jid, nick, password) | |
576 ) | |
577 else: | |
578 await defer.ensureDeferred( | |
579 self._join_cb(room, client, room_jid, nick) | |
580 ) | |
581 return room | |
582 | |
583 def pop_rooms(self, client): | |
584 """Remove rooms and return data needed to re-join them | |
585 | |
586 This methods is to be called before a hot reconnection | |
587 @return (list[(jid.JID, unicode)]): arguments needed to re-join the rooms | |
588 This list can be used directly (unpacked) with self.join | |
589 """ | |
590 args_list = [] | |
591 for room in list(client._muc_client.joined_rooms.values()): | |
592 client._muc_client._removeRoom(room.roomJID) | |
593 args_list.append((client, room.roomJID, room.nick)) | |
594 return args_list | |
595 | |
596 def _nick(self, room_jid_s, nick, profile_key=C.PROF_KEY_NONE): | |
597 client = self.host.get_client(profile_key) | |
598 return self.nick(client, jid.JID(room_jid_s), nick) | |
599 | |
600 def nick(self, client, room_jid, nick): | |
601 """Change nickname in a room""" | |
602 self.check_room_joined(client, room_jid) | |
603 return client._muc_client.nick(room_jid, nick) | |
604 | |
605 def _leave(self, room_jid, profile_key): | |
606 client = self.host.get_client(profile_key) | |
607 return self.leave(client, jid.JID(room_jid)) | |
608 | |
609 def leave(self, client, room_jid): | |
610 self.check_room_joined(client, room_jid) | |
611 return client._muc_client.leave(room_jid) | |
612 | |
613 def _subject(self, room_jid_s, new_subject, profile_key): | |
614 client = self.host.get_client(profile_key) | |
615 return self.subject(client, jid.JID(room_jid_s), new_subject) | |
616 | |
617 def subject(self, client, room_jid, subject): | |
618 self.check_room_joined(client, room_jid) | |
619 return client._muc_client.subject(room_jid, subject) | |
620 | |
621 def get_handler(self, client): | |
622 # create a MUC client and associate it with profile' session | |
623 muc_client = client._muc_client = LiberviaMUCClient(self) | |
624 return muc_client | |
625 | |
626 def kick(self, client, nick, room_jid, options=None): | |
627 """Kick a participant from the room | |
628 | |
629 @param nick (str): nick of the user to kick | |
630 @param room_jid_s (JID): jid of the room | |
631 @param options (dict): attribute with extra info (reason, password) as in #XEP-0045 | |
632 """ | |
633 if options is None: | |
634 options = {} | |
635 self.check_room_joined(client, room_jid) | |
636 return client._muc_client.kick(room_jid, nick, reason=options.get('reason', None)) | |
637 | |
638 def ban(self, client, entity_jid, room_jid, options=None): | |
639 """Ban an entity from the room | |
640 | |
641 @param entity_jid (JID): bare jid of the entity to be banned | |
642 @param room_jid (JID): jid of the room | |
643 @param options: attribute with extra info (reason, password) as in #XEP-0045 | |
644 """ | |
645 self.check_room_joined(client, room_jid) | |
646 if options is None: | |
647 options = {} | |
648 assert not entity_jid.resource | |
649 assert not room_jid.resource | |
650 return client._muc_client.ban(room_jid, entity_jid, reason=options.get('reason', None)) | |
651 | |
652 def affiliate(self, client, entity_jid, room_jid, options): | |
653 """Change the affiliation of an entity | |
654 | |
655 @param entity_jid (JID): bare jid of the entity | |
656 @param room_jid_s (JID): jid of the room | |
657 @param options: attribute with extra info (reason, nick) as in #XEP-0045 | |
658 """ | |
659 self.check_room_joined(client, room_jid) | |
660 assert not entity_jid.resource | |
661 assert not room_jid.resource | |
662 assert 'affiliation' in options | |
663 # TODO: handles reason and nick | |
664 return client._muc_client.modifyAffiliationList(room_jid, [entity_jid], options['affiliation']) | |
665 | |
666 # Text commands # | |
667 | |
668 def cmd_nick(self, client, mess_data): | |
669 """change nickname | |
670 | |
671 @command (group): new_nick | |
672 - new_nick: new nick to use | |
673 """ | |
674 nick = mess_data["unparsed"].strip() | |
675 if nick: | |
676 room = mess_data["to"] | |
677 self.nick(client, room, nick) | |
678 | |
679 return False | |
680 | |
681 def cmd_join(self, client, mess_data): | |
682 """join a new room | |
683 | |
684 @command (all): JID | |
685 - JID: room to join (on the same service if full jid is not specified) | |
686 """ | |
687 room_raw = mess_data["unparsed"].strip() | |
688 if room_raw: | |
689 if self.is_joined_room(client, mess_data["to"]): | |
690 # we use the same service as the one from the room where the command has | |
691 # been entered if full jid is not entered | |
692 muc_service = mess_data["to"].host | |
693 nick = self.get_room_nick(client, mess_data["to"]) or client.jid.user | |
694 else: | |
695 # the command has been entered in a one2one conversation, so we use | |
696 # our server MUC service as default service | |
697 muc_service = client.muc_service or "" | |
698 nick = client.jid.user | |
699 room_jid = self.text_cmds.get_room_jid(room_raw, muc_service) | |
700 self.join(client, room_jid, nick, {}) | |
701 | |
702 return False | |
703 | |
704 def cmd_leave(self, client, mess_data): | |
705 """quit a room | |
706 | |
707 @command (group): [ROOM_JID] | |
708 - ROOM_JID: jid of the room to live (current room if not specified) | |
709 """ | |
710 room_raw = mess_data["unparsed"].strip() | |
711 if room_raw: | |
712 room = self.text_cmds.get_room_jid(room_raw, mess_data["to"].host) | |
713 else: | |
714 room = mess_data["to"] | |
715 | |
716 self.leave(client, room) | |
717 | |
718 return False | |
719 | |
720 def cmd_part(self, client, mess_data): | |
721 """just a synonym of /leave | |
722 | |
723 @command (group): [ROOM_JID] | |
724 - ROOM_JID: jid of the room to live (current room if not specified) | |
725 """ | |
726 return self.cmd_leave(client, mess_data) | |
727 | |
728 def cmd_kick(self, client, mess_data): | |
729 """kick a room member | |
730 | |
731 @command (group): ROOM_NICK | |
732 - ROOM_NICK: the nick of the person to kick | |
733 """ | |
734 options = mess_data["unparsed"].strip().split() | |
735 try: | |
736 nick = options[0] | |
737 assert self.is_nick_in_room(client, mess_data["to"], nick) | |
738 except (IndexError, AssertionError): | |
739 feedback = _("You must provide a member's nick to kick.") | |
740 self.text_cmds.feed_back(client, feedback, mess_data) | |
741 return False | |
742 | |
743 reason = ' '.join(options[1:]) if len(options) > 1 else None | |
744 | |
745 d = self.kick(client, nick, mess_data["to"], {"reason": reason}) | |
746 | |
747 def cb(__): | |
748 feedback_msg = _('You have kicked {}').format(nick) | |
749 if reason is not None: | |
750 feedback_msg += _(' for the following reason: {reason}').format( | |
751 reason=reason | |
752 ) | |
753 self.text_cmds.feed_back(client, feedback_msg, mess_data) | |
754 return True | |
755 d.addCallback(cb) | |
756 return d | |
757 | |
758 def cmd_ban(self, client, mess_data): | |
759 """ban an entity from the room | |
760 | |
761 @command (group): (JID) [reason] | |
762 - JID: the JID of the entity to ban | |
763 - reason: the reason why this entity is being banned | |
764 """ | |
765 options = mess_data["unparsed"].strip().split() | |
766 try: | |
767 jid_s = options[0] | |
768 entity_jid = jid.JID(jid_s).userhostJID() | |
769 assert(entity_jid.user) | |
770 assert(entity_jid.host) | |
771 except (RuntimeError, jid.InvalidFormat, AttributeError, IndexError, | |
772 AssertionError): | |
773 feedback = _( | |
774 "You must provide a valid JID to ban, like in '/ban contact@example.net'" | |
775 ) | |
776 self.text_cmds.feed_back(client, feedback, mess_data) | |
777 return False | |
778 | |
779 reason = ' '.join(options[1:]) if len(options) > 1 else None | |
780 | |
781 d = self.ban(client, entity_jid, mess_data["to"], {"reason": reason}) | |
782 | |
783 def cb(__): | |
784 feedback_msg = _('You have banned {}').format(entity_jid) | |
785 if reason is not None: | |
786 feedback_msg += _(' for the following reason: {reason}').format( | |
787 reason=reason | |
788 ) | |
789 self.text_cmds.feed_back(client, feedback_msg, mess_data) | |
790 return True | |
791 d.addCallback(cb) | |
792 return d | |
793 | |
794 def cmd_affiliate(self, client, mess_data): | |
795 """affiliate an entity to the room | |
796 | |
797 @command (group): (JID) [owner|admin|member|none|outcast] | |
798 - JID: the JID of the entity to affiliate | |
799 - owner: grant owner privileges | |
800 - admin: grant admin privileges | |
801 - member: grant member privileges | |
802 - none: reset entity privileges | |
803 - outcast: ban entity | |
804 """ | |
805 options = mess_data["unparsed"].strip().split() | |
806 try: | |
807 jid_s = options[0] | |
808 entity_jid = jid.JID(jid_s).userhostJID() | |
809 assert(entity_jid.user) | |
810 assert(entity_jid.host) | |
811 except (RuntimeError, jid.InvalidFormat, AttributeError, IndexError, AssertionError): | |
812 feedback = _("You must provide a valid JID to affiliate, like in '/affiliate contact@example.net member'") | |
813 self.text_cmds.feed_back(client, feedback, mess_data) | |
814 return False | |
815 | |
816 affiliation = options[1] if len(options) > 1 else 'none' | |
817 if affiliation not in AFFILIATIONS: | |
818 feedback = _("You must provide a valid affiliation: %s") % ' '.join(AFFILIATIONS) | |
819 self.text_cmds.feed_back(client, feedback, mess_data) | |
820 return False | |
821 | |
822 d = self.affiliate(client, entity_jid, mess_data["to"], {'affiliation': affiliation}) | |
823 | |
824 def cb(__): | |
825 feedback_msg = _('New affiliation for {entity}: {affiliation}').format( | |
826 entity=entity_jid, affiliation=affiliation) | |
827 self.text_cmds.feed_back(client, feedback_msg, mess_data) | |
828 return True | |
829 d.addCallback(cb) | |
830 return d | |
831 | |
832 def cmd_title(self, client, mess_data): | |
833 """change room's subject | |
834 | |
835 @command (group): title | |
836 - title: new room subject | |
837 """ | |
838 subject = mess_data["unparsed"].strip() | |
839 | |
840 if subject: | |
841 room = mess_data["to"] | |
842 self.subject(client, room, subject) | |
843 | |
844 return False | |
845 | |
846 def cmd_topic(self, client, mess_data): | |
847 """just a synonym of /title | |
848 | |
849 @command (group): title | |
850 - title: new room subject | |
851 """ | |
852 return self.cmd_title(client, mess_data) | |
853 | |
854 def cmd_list(self, client, mess_data): | |
855 """list available rooms in a muc server | |
856 | |
857 @command (all): [MUC_SERVICE] | |
858 - MUC_SERVICE: service to request | |
859 empty value will request room's service for a room, | |
860 or user's server default MUC service in a one2one chat | |
861 """ | |
862 unparsed = mess_data["unparsed"].strip() | |
863 try: | |
864 service = jid.JID(unparsed) | |
865 except RuntimeError: | |
866 if mess_data['type'] == C.MESS_TYPE_GROUPCHAT: | |
867 room_jid = mess_data["to"] | |
868 service = jid.JID(room_jid.host) | |
869 elif client.muc_service is not None: | |
870 service = client.muc_service | |
871 else: | |
872 msg = D_("No known default MUC service {unparsed}").format( | |
873 unparsed=unparsed) | |
874 self.text_cmds.feed_back(client, msg, mess_data) | |
875 return False | |
876 except jid.InvalidFormat: | |
877 msg = D_("{} is not a valid JID!".format(unparsed)) | |
878 self.text_cmds.feed_back(client, msg, mess_data) | |
879 return False | |
880 d = self.host.getDiscoItems(client, service) | |
881 d.addCallback(self._show_list_ui, client, service) | |
882 | |
883 return False | |
884 | |
885 def _whois(self, client, whois_msg, mess_data, target_jid): | |
886 """ Add MUC user information to whois """ | |
887 if mess_data['type'] != "groupchat": | |
888 return | |
889 if target_jid.userhostJID() not in client._muc_client.joined_rooms: | |
890 log.warning(_("This room has not been joined")) | |
891 return | |
892 if not target_jid.resource: | |
893 return | |
894 user = client._muc_client.joined_rooms[target_jid.userhostJID()].getUser(target_jid.resource) | |
895 whois_msg.append(_("Nickname: %s") % user.nick) | |
896 if user.entity: | |
897 whois_msg.append(_("Entity: %s") % user.entity) | |
898 if user.affiliation != 'none': | |
899 whois_msg.append(_("Affiliation: %s") % user.affiliation) | |
900 if user.role != 'none': | |
901 whois_msg.append(_("Role: %s") % user.role) | |
902 if user.status: | |
903 whois_msg.append(_("Status: %s") % user.status) | |
904 if user.show: | |
905 whois_msg.append(_("Show: %s") % user.show) | |
906 | |
907 def presence_trigger(self, presence_elt, client): | |
908 # FIXME: should we add a privacy parameters in settings to activate before | |
909 # broadcasting the presence to all MUC rooms ? | |
910 muc_client = client._muc_client | |
911 for room_jid, room in muc_client.joined_rooms.items(): | |
912 elt = xml_tools.element_copy(presence_elt) | |
913 elt['to'] = room_jid.userhost() + '/' + room.nick | |
914 client.presence.send(elt) | |
915 return True | |
916 | |
917 def presence_received_trigger(self, client, entity, show, priority, statuses): | |
918 entity_bare = entity.userhostJID() | |
919 muc_client = client._muc_client | |
920 if entity_bare in muc_client.joined_rooms: | |
921 # presence is already handled in (un)availableReceived | |
922 return False | |
923 return True | |
924 | |
925 | |
926 @implementer(iwokkel.IDisco) | |
927 class LiberviaMUCClient(muc.MUCClient): | |
928 | |
929 def __init__(self, plugin_parent): | |
930 self.plugin_parent = plugin_parent | |
931 muc.MUCClient.__init__(self) | |
932 self._changing_nicks = set() # used to keep trace of who is changing nick, | |
933 # and to discard userJoinedRoom signal in this case | |
934 print("init SatMUCClient OK") | |
935 | |
936 @property | |
937 def joined_rooms(self): | |
938 return self._rooms | |
939 | |
940 @property | |
941 def host(self): | |
942 return self.plugin_parent.host | |
943 | |
944 @property | |
945 def client(self): | |
946 return self.parent | |
947 | |
948 @property | |
949 def _mam(self): | |
950 return self.plugin_parent._mam | |
951 | |
952 @property | |
953 def _si(self): | |
954 return self.plugin_parent._si | |
955 | |
956 def change_room_state(self, room, new_state): | |
957 """Check that room is in expected state, and change it | |
958 | |
959 @param new_state: one of ROOM_STATE_* | |
960 """ | |
961 new_state_idx = ROOM_STATES.index(new_state) | |
962 if new_state_idx == -1: | |
963 raise exceptions.InternalError("unknown room state") | |
964 if new_state_idx < 1: | |
965 raise exceptions.InternalError("unexpected new room state ({room}): {state}".format( | |
966 room=room.userhost(), | |
967 state=new_state)) | |
968 expected_state = ROOM_STATES[new_state_idx-1] | |
969 if room.state != expected_state: | |
970 log.error(_( | |
971 "room {room} is not in expected state: room is in state {current_state} " | |
972 "while we were expecting {expected_state}").format( | |
973 room=room.roomJID.userhost(), | |
974 current_state=room.state, | |
975 expected_state=expected_state)) | |
976 room.state = new_state | |
977 | |
978 def _addRoom(self, room): | |
979 super(LiberviaMUCClient, self)._addRoom(room) | |
980 room._roster_ok = False # True when occupants list has been fully received | |
981 room.state = ROOM_STATE_OCCUPANTS | |
982 # FIXME: check if history_d is not redundant with fully_joined | |
983 room.fully_joined = defer.Deferred() # called when everything is OK | |
984 # cache data until room is ready | |
985 # list of elements which will be re-injected in stream | |
986 room._cache = [] | |
987 # we only need to keep last presence status for each jid, so a dict is suitable | |
988 room._cache_presence = {} | |
989 | |
990 async def _join_legacy( | |
991 self, | |
992 client: SatXMPPEntity, | |
993 room_jid: jid.JID, | |
994 nick: str, | |
995 password: Optional[str] | |
996 ) -> muc.Room: | |
997 """Join room an retrieve history with legacy method""" | |
998 mess_data_list = await self.host.memory.history_get( | |
999 room_jid, | |
1000 client.jid.userhostJID(), | |
1001 limit=1, | |
1002 between=True, | |
1003 profile=client.profile | |
1004 ) | |
1005 if mess_data_list: | |
1006 timestamp = mess_data_list[0][1] | |
1007 # we use seconds since last message to get backlog without duplicates | |
1008 # and we remove 1 second to avoid getting the last message again | |
1009 seconds = int(time.time() - timestamp) - 1 | |
1010 else: | |
1011 seconds = None | |
1012 | |
1013 room = await super(LiberviaMUCClient, self).join( | |
1014 room_jid, nick, muc.HistoryOptions(seconds=seconds), password) | |
1015 # used to send bridge signal once backlog are written in history | |
1016 room._history_type = HISTORY_LEGACY | |
1017 room._history_d = defer.Deferred() | |
1018 room._history_d.callback(None) | |
1019 return room | |
1020 | |
1021 async def _get_mam_history( | |
1022 self, | |
1023 client: SatXMPPEntity, | |
1024 room: muc.Room, | |
1025 room_jid: jid.JID | |
1026 ) -> None: | |
1027 """Retrieve history for rooms handling MAM""" | |
1028 history_d = room._history_d = defer.Deferred() | |
1029 # we trigger now the deferred so all callback are processed as soon as possible | |
1030 # and in order | |
1031 history_d.callback(None) | |
1032 | |
1033 last_mess = await self.host.memory.history_get( | |
1034 room_jid, | |
1035 None, | |
1036 limit=1, | |
1037 between=False, | |
1038 filters={ | |
1039 'types': C.MESS_TYPE_GROUPCHAT, | |
1040 'last_stanza_id': True}, | |
1041 profile=client.profile) | |
1042 if last_mess: | |
1043 stanza_id = last_mess[0][-1]['stanza_id'] | |
1044 rsm_req = rsm.RSMRequest(max_=20, after=stanza_id) | |
1045 no_loop=False | |
1046 else: | |
1047 log.info("We have no MAM archive for room {room_jid}.".format( | |
1048 room_jid=room_jid)) | |
1049 # we don't want the whole archive if we have no archive yet | |
1050 # as it can be huge | |
1051 rsm_req = rsm.RSMRequest(max_=50, before='') | |
1052 no_loop=True | |
1053 | |
1054 mam_req = mam.MAMRequest(rsm_=rsm_req) | |
1055 complete = False | |
1056 count = 0 | |
1057 while not complete: | |
1058 try: | |
1059 mam_data = await self._mam.get_archives(client, mam_req, | |
1060 service=room_jid) | |
1061 except xmpp_error.StanzaError as e: | |
1062 if last_mess and e.condition == 'item-not-found': | |
1063 log.info( | |
1064 f"requested item (with id {stanza_id!r}) can't be found in " | |
1065 f"history of {room_jid}, history has probably been purged on " | |
1066 f"server.") | |
1067 # we get last items like for a new room | |
1068 rsm_req = rsm.RSMRequest(max_=50, before='') | |
1069 mam_req = mam.MAMRequest(rsm_=rsm_req) | |
1070 no_loop=True | |
1071 continue | |
1072 else: | |
1073 raise e | |
1074 elt_list, rsm_response, mam_response = mam_data | |
1075 complete = True if no_loop else mam_response["complete"] | |
1076 # we update MAM request for next iteration | |
1077 mam_req.rsm.after = rsm_response.last | |
1078 | |
1079 if not elt_list: | |
1080 break | |
1081 else: | |
1082 count += len(elt_list) | |
1083 | |
1084 for mess_elt in elt_list: | |
1085 try: | |
1086 fwd_message_elt = self._mam.get_message_from_result( | |
1087 client, mess_elt, mam_req, service=room_jid) | |
1088 except exceptions.DataError: | |
1089 continue | |
1090 if fwd_message_elt.getAttribute("to"): | |
1091 log.warning( | |
1092 'Forwarded message element has a "to" attribute while it is ' | |
1093 'forbidden by specifications') | |
1094 fwd_message_elt["to"] = client.jid.full() | |
1095 try: | |
1096 mess_data = client.messageProt.parse_message(fwd_message_elt) | |
1097 except Exception as e: | |
1098 log.error( | |
1099 f"Can't parse message, ignoring it: {e}\n" | |
1100 f"{fwd_message_elt.toXml()}" | |
1101 ) | |
1102 continue | |
1103 # we attache parsed message data to element, to avoid parsing | |
1104 # again in _add_to_history | |
1105 fwd_message_elt._mess_data = mess_data | |
1106 # and we inject to MUC workflow | |
1107 client._muc_client._onGroupChat(fwd_message_elt) | |
1108 | |
1109 if not count: | |
1110 log.info(_("No message received while offline in {room_jid}".format( | |
1111 room_jid=room_jid))) | |
1112 else: | |
1113 log.info( | |
1114 _("We have received {num_mess} message(s) in {room_jid} while " | |
1115 "offline.") | |
1116 .format(num_mess=count, room_jid=room_jid)) | |
1117 | |
1118 # for legacy history, the following steps are done in receivedSubject but for MAM | |
1119 # the order is different (we have to join then get MAM archive, so subject | |
1120 # is received before archive), so we change state and add the callbacks here. | |
1121 self.change_room_state(room, ROOM_STATE_LIVE) | |
1122 history_d.addCallbacks(self._history_cb, self._history_eb, [room], | |
1123 errbackArgs=[room]) | |
1124 | |
1125 # we wait for all callbacks to be processed | |
1126 await history_d | |
1127 | |
1128 async def _join_mam( | |
1129 self, | |
1130 client: SatXMPPEntity, | |
1131 room_jid: jid.JID, | |
1132 nick: str, | |
1133 password: Optional[str] | |
1134 ) -> muc.Room: | |
1135 """Join room and retrieve history using MAM""" | |
1136 room = await super(LiberviaMUCClient, self).join( | |
1137 # we don't want any history from room as we'll get it with MAM | |
1138 room_jid, nick, muc.HistoryOptions(maxStanzas=0), password=password | |
1139 ) | |
1140 room._history_type = HISTORY_MAM | |
1141 # MAM history retrieval can be very long, and doesn't need to be sync, so we don't | |
1142 # wait for it | |
1143 defer.ensureDeferred(self._get_mam_history(client, room, room_jid)) | |
1144 room.fully_joined.callback(room) | |
1145 | |
1146 return room | |
1147 | |
1148 async def join(self, room_jid, nick, password=None): | |
1149 room_service = jid.JID(room_jid.host) | |
1150 has_mam = await self.host.hasFeature(self.client, mam.NS_MAM, room_service) | |
1151 if not self._mam or not has_mam: | |
1152 return await self._join_legacy(self.client, room_jid, nick, password) | |
1153 else: | |
1154 return await self._join_mam(self.client, room_jid, nick, password) | |
1155 | |
1156 ## presence/roster ## | |
1157 | |
1158 def availableReceived(self, presence): | |
1159 """ | |
1160 Available presence was received. | |
1161 """ | |
1162 # XXX: we override MUCClient.availableReceived to fix bugs | |
1163 # (affiliation and role are not set) | |
1164 | |
1165 room, user = self._getRoomUser(presence) | |
1166 | |
1167 if room is None: | |
1168 return | |
1169 | |
1170 if user is None: | |
1171 nick = presence.sender.resource | |
1172 if not nick: | |
1173 log.warning(_("missing nick in presence: {xml}").format( | |
1174 xml = presence.toElement().toXml())) | |
1175 return | |
1176 user = muc.User(nick, presence.entity) | |
1177 | |
1178 # we want to keep statuses with room | |
1179 # XXX: presence if broadcasted, and we won't have status code | |
1180 # like 110 (REALJID_PUBLIC) after first <presence/> received | |
1181 # so we keep only the initial <presence> (with SELF_PRESENCE), | |
1182 # thus we check if attribute already exists | |
1183 if (not hasattr(room, 'statuses') | |
1184 and muc.STATUS_CODE.SELF_PRESENCE in presence.mucStatuses): | |
1185 room.statuses = presence.mucStatuses | |
1186 | |
1187 # Update user data | |
1188 user.role = presence.role | |
1189 user.affiliation = presence.affiliation | |
1190 user.status = presence.status | |
1191 user.show = presence.show | |
1192 | |
1193 if room.inRoster(user): | |
1194 self.userUpdatedStatus(room, user, presence.show, presence.status) | |
1195 else: | |
1196 room.addUser(user) | |
1197 self.userJoinedRoom(room, user) | |
1198 | |
1199 def unavailableReceived(self, presence): | |
1200 # XXX: we override this method to manage nickname change | |
1201 """ | |
1202 Unavailable presence was received. | |
1203 | |
1204 If this was received from a MUC room occupant JID, that occupant has | |
1205 left the room. | |
1206 """ | |
1207 room, user = self._getRoomUser(presence) | |
1208 | |
1209 if room is None or user is None: | |
1210 return | |
1211 | |
1212 room.removeUser(user) | |
1213 | |
1214 if muc.STATUS_CODE.NEW_NICK in presence.mucStatuses: | |
1215 self._changing_nicks.add(presence.nick) | |
1216 self.user_changed_nick(room, user, presence.nick) | |
1217 else: | |
1218 self._changing_nicks.discard(presence.nick) | |
1219 self.userLeftRoom(room, user) | |
1220 | |
1221 def userJoinedRoom(self, room, user): | |
1222 if user.nick == room.nick: | |
1223 # we have received our own nick, | |
1224 # this mean that the full room roster was received | |
1225 self.change_room_state(room, ROOM_STATE_SELF_PRESENCE) | |
1226 log.debug("room {room} joined with nick {nick}".format( | |
1227 room=room.occupantJID.userhost(), nick=user.nick)) | |
1228 # we set type so we don't have to use a deferred | |
1229 # with disco to check entity type | |
1230 self.host.memory.update_entity_data( | |
1231 self.client, room.roomJID, C.ENTITY_TYPE, C.ENTITY_TYPE_MUC | |
1232 ) | |
1233 elif room.state not in (ROOM_STATE_OCCUPANTS, ROOM_STATE_LIVE): | |
1234 log.warning( | |
1235 "Received user presence data in a room before its initialisation " | |
1236 "(current state: {state})," | |
1237 "this is not standard! Ignoring it: {room} ({nick})".format( | |
1238 state=room.state, | |
1239 room=room.roomJID.userhost(), | |
1240 nick=user.nick)) | |
1241 return | |
1242 else: | |
1243 if not room.fully_joined.called: | |
1244 return | |
1245 try: | |
1246 self._changing_nicks.remove(user.nick) | |
1247 except KeyError: | |
1248 # this is a new user | |
1249 log.debug(_("user {nick} has joined room {room_id}").format( | |
1250 nick=user.nick, room_id=room.occupantJID.userhost())) | |
1251 if not self.host.trigger.point( | |
1252 "MUC user joined", room, user, self.client.profile): | |
1253 return | |
1254 | |
1255 extra = {'info_type': ROOM_USER_JOINED, | |
1256 'user_affiliation': user.affiliation, | |
1257 'user_role': user.role, | |
1258 'user_nick': user.nick | |
1259 } | |
1260 if user.entity is not None: | |
1261 extra['user_entity'] = user.entity.full() | |
1262 mess_data = { # dict is similar to the one used in client.onMessage | |
1263 "from": room.roomJID, | |
1264 "to": self.client.jid, | |
1265 "uid": str(uuid.uuid4()), | |
1266 "message": {'': D_("=> {} has joined the room").format(user.nick)}, | |
1267 "subject": {}, | |
1268 "type": C.MESS_TYPE_INFO, | |
1269 "extra": extra, | |
1270 "timestamp": time.time(), | |
1271 } | |
1272 # FIXME: we disable presence in history as it's taking a lot of space | |
1273 # while not indispensable. In the future an option may allow | |
1274 # to re-enable it | |
1275 # self.client.message_add_to_history(mess_data) | |
1276 self.client.message_send_to_bridge(mess_data) | |
1277 | |
1278 | |
1279 def userLeftRoom(self, room, user): | |
1280 if not self.host.trigger.point("MUC user left", room, user, self.client.profile): | |
1281 return | |
1282 if user.nick == room.nick: | |
1283 # we left the room | |
1284 room_jid_s = room.roomJID.userhost() | |
1285 log.info(_("Room ({room}) left ({profile})").format( | |
1286 room = room_jid_s, profile = self.client.profile)) | |
1287 self.host.memory.del_entity_cache(room.roomJID, profile_key=self.client.profile) | |
1288 self.host.bridge.muc_room_left(room.roomJID.userhost(), self.client.profile) | |
1289 elif room.state != ROOM_STATE_LIVE: | |
1290 log.warning("Received user presence data in a room before its initialisation (current state: {state})," | |
1291 "this is not standard! Ignoring it: {room} ({nick})".format( | |
1292 state=room.state, | |
1293 room=room.roomJID.userhost(), | |
1294 nick=user.nick)) | |
1295 return | |
1296 else: | |
1297 if not room.fully_joined.called: | |
1298 return | |
1299 log.debug(_("user {nick} left room {room_id}").format(nick=user.nick, room_id=room.occupantJID.userhost())) | |
1300 extra = {'info_type': ROOM_USER_LEFT, | |
1301 'user_affiliation': user.affiliation, | |
1302 'user_role': user.role, | |
1303 'user_nick': user.nick | |
1304 } | |
1305 if user.entity is not None: | |
1306 extra['user_entity'] = user.entity.full() | |
1307 mess_data = { # dict is similar to the one used in client.onMessage | |
1308 "from": room.roomJID, | |
1309 "to": self.client.jid, | |
1310 "uid": str(uuid.uuid4()), | |
1311 "message": {'': D_("<= {} has left the room").format(user.nick)}, | |
1312 "subject": {}, | |
1313 "type": C.MESS_TYPE_INFO, | |
1314 "extra": extra, | |
1315 "timestamp": time.time(), | |
1316 } | |
1317 # FIXME: disable history, see userJoinRoom comment | |
1318 # self.client.message_add_to_history(mess_data) | |
1319 self.client.message_send_to_bridge(mess_data) | |
1320 | |
1321 def user_changed_nick(self, room, user, new_nick): | |
1322 self.host.bridge.muc_room_user_changed_nick(room.roomJID.userhost(), user.nick, new_nick, self.client.profile) | |
1323 | |
1324 def userUpdatedStatus(self, room, user, show, status): | |
1325 entity = jid.JID(tuple=(room.roomJID.user, room.roomJID.host, user.nick)) | |
1326 if hasattr(room, "_cache_presence"): | |
1327 # room has a cache for presence, meaning it has not been fully | |
1328 # joined yet. So we put presence in cache, and stop workflow. | |
1329 # Or delete current presence and continue workflow if it's an | |
1330 # "unavailable" presence | |
1331 cache = room._cache_presence | |
1332 cache[entity] = { | |
1333 "room": room, | |
1334 "user": user, | |
1335 "show": show, | |
1336 "status": status, | |
1337 } | |
1338 return | |
1339 statuses = {C.PRESENCE_STATUSES_DEFAULT: status or ''} | |
1340 self.host.bridge.presence_update( | |
1341 entity.full(), show or '', 0, statuses, self.client.profile) | |
1342 | |
1343 ## messages ## | |
1344 | |
1345 def receivedGroupChat(self, room, user, body): | |
1346 log.debug('receivedGroupChat: room=%s user=%s body=%s' % (room.roomJID.full(), user, body)) | |
1347 | |
1348 def _add_to_history(self, __, user, message): | |
1349 try: | |
1350 # message can be already parsed (with MAM), in this case mess_data | |
1351 # it attached to the element | |
1352 mess_data = message.element._mess_data | |
1353 except AttributeError: | |
1354 mess_data = self.client.messageProt.parse_message(message.element) | |
1355 if mess_data['message'] or mess_data['subject']: | |
1356 return defer.ensureDeferred( | |
1357 self.host.memory.add_to_history(self.client, mess_data) | |
1358 ) | |
1359 else: | |
1360 return defer.succeed(None) | |
1361 | |
1362 def _add_to_history_eb(self, failure): | |
1363 failure.trap(exceptions.CancelError) | |
1364 | |
1365 def receivedHistory(self, room, user, message): | |
1366 """Called when history (backlog) message are received | |
1367 | |
1368 we check if message is not already in our history | |
1369 and add it if needed | |
1370 @param room(muc.Room): room instance | |
1371 @param user(muc.User, None): the user that sent the message | |
1372 None if the message come from the room | |
1373 @param message(muc.GroupChat): the parsed message | |
1374 """ | |
1375 if room.state != ROOM_STATE_SELF_PRESENCE: | |
1376 log.warning(_( | |
1377 "received history in unexpected state in room {room} (state: " | |
1378 "{state})").format(room = room.roomJID.userhost(), | |
1379 state = room.state)) | |
1380 if not hasattr(room, "_history_d"): | |
1381 # XXX: this hack is due to buggy behaviour seen in the wild because of the | |
1382 # "mod_delay" prosody module being activated. This module add an | |
1383 # unexpected <delay> elements which break our workflow. | |
1384 log.warning(_("storing the unexpected message anyway, to avoid loss")) | |
1385 # we have to restore URI which are stripped by wokkel parsing | |
1386 for c in message.element.elements(): | |
1387 if c.uri is None: | |
1388 c.uri = C.NS_CLIENT | |
1389 mess_data = self.client.messageProt.parse_message(message.element) | |
1390 message.element._mess_data = mess_data | |
1391 self._add_to_history(None, user, message) | |
1392 if mess_data['message'] or mess_data['subject']: | |
1393 self.host.bridge.message_new( | |
1394 *self.client.message_get_bridge_args(mess_data), | |
1395 profile=self.client.profile | |
1396 ) | |
1397 return | |
1398 room._history_d.addCallback(self._add_to_history, user, message) | |
1399 room._history_d.addErrback(self._add_to_history_eb) | |
1400 | |
1401 ## subject ## | |
1402 | |
1403 def groupChatReceived(self, message): | |
1404 """ | |
1405 A group chat message has been received from a MUC room. | |
1406 | |
1407 There are a few event methods that may get called here. | |
1408 L{receivedGroupChat}, L{receivedSubject} or L{receivedHistory}. | |
1409 """ | |
1410 # We override this method to fix subject handling (empty strings were discarded) | |
1411 # FIXME: remove this once fixed upstream | |
1412 room, user = self._getRoomUser(message) | |
1413 | |
1414 if room is None: | |
1415 log.warning("No room found for message: {message}" | |
1416 .format(message=message.toElement().toXml())) | |
1417 return | |
1418 | |
1419 if message.subject is not None: | |
1420 self.receivedSubject(room, user, message.subject) | |
1421 elif message.delay is None: | |
1422 self.receivedGroupChat(room, user, message) | |
1423 else: | |
1424 self.receivedHistory(room, user, message) | |
1425 | |
1426 def subject(self, room, subject): | |
1427 return muc.MUCClientProtocol.subject(self, room, subject) | |
1428 | |
1429 def _history_cb(self, __, room): | |
1430 """Called when history have been written to database and subject is received | |
1431 | |
1432 this method will finish joining by: | |
1433 - sending message to bridge | |
1434 - calling fully_joined deferred (for legacy history) | |
1435 - sending stanza put in cache | |
1436 - cleaning variables not needed anymore | |
1437 """ | |
1438 args = self.plugin_parent._get_room_joined_args(room, self.client.profile) | |
1439 self.host.bridge.muc_room_joined(*args) | |
1440 if room._history_type == HISTORY_LEGACY: | |
1441 room.fully_joined.callback(room) | |
1442 del room._history_d | |
1443 del room._history_type | |
1444 cache = room._cache | |
1445 del room._cache | |
1446 cache_presence = room._cache_presence | |
1447 del room._cache_presence | |
1448 for elem in cache: | |
1449 self.client.xmlstream.dispatch(elem) | |
1450 for presence_data in cache_presence.values(): | |
1451 if not presence_data['show'] and not presence_data['status']: | |
1452 # occupants are already sent in muc_room_joined, so if we don't have | |
1453 # extra information like show or statuses, we can discard the signal | |
1454 continue | |
1455 else: | |
1456 self.userUpdatedStatus(**presence_data) | |
1457 | |
1458 def _history_eb(self, failure_, room): | |
1459 log.error("Error while managing history: {}".format(failure_)) | |
1460 self._history_cb(None, room) | |
1461 | |
1462 def receivedSubject(self, room, user, subject): | |
1463 # when subject is received, we know that we have whole roster and history | |
1464 # cf. http://xmpp.org/extensions/xep-0045.html#enter-subject | |
1465 room.subject = subject # FIXME: subject doesn't handle xml:lang | |
1466 if room.state != ROOM_STATE_LIVE: | |
1467 if room._history_type == HISTORY_LEGACY: | |
1468 self.change_room_state(room, ROOM_STATE_LIVE) | |
1469 room._history_d.addCallbacks(self._history_cb, self._history_eb, [room], errbackArgs=[room]) | |
1470 else: | |
1471 # the subject has been changed | |
1472 log.debug(_("New subject for room ({room_id}): {subject}").format(room_id = room.roomJID.full(), subject = subject)) | |
1473 self.host.bridge.muc_room_new_subject(room.roomJID.userhost(), subject, self.client.profile) | |
1474 | |
1475 ## disco ## | |
1476 | |
1477 def getDiscoInfo(self, requestor, target, nodeIdentifier=''): | |
1478 return [disco.DiscoFeature(NS_MUC)] | |
1479 | |
1480 def getDiscoItems(self, requestor, target, nodeIdentifier=''): | |
1481 # TODO: manage room queries ? Bad for privacy, must be disabled by default | |
1482 # see XEP-0045 § 6.7 | |
1483 return [] |