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