Mercurial > libervia-backend
diff sat/plugins/plugin_xep_0045.py @ 2562:26edcf3a30eb
core, setup: huge cleaning:
- moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention
- move twisted directory to root
- removed all hacks from setup.py, and added missing dependencies, it is now clean
- use https URL for website in setup.py
- removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed
- renamed sat.sh to sat and fixed its installation
- added python_requires to specify Python version needed
- replaced glib2reactor which use deprecated code by gtk3reactor
sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 02 Apr 2018 19:44:50 +0200 |
parents | src/plugins/plugin_xep_0045.py@0046283a285d |
children | 395a3d1c2888 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0045.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,1062 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0045 +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from twisted.python import failure +from dateutil.tz import tzutc + +from sat.core import exceptions +from sat.memory import memory + +import calendar +import time +import uuid +import copy + +from wokkel import muc, disco, iwokkel +from sat.tools import xml_tools + +from zope.interface import implements + + +PLUGIN_INFO = { + C.PI_NAME: "XEP-0045 Plugin", + C.PI_IMPORT_NAME: "XEP-0045", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0045"], + C.PI_DEPENDENCIES: [], + C.PI_RECOMMENDATIONS: [C.TEXT_CMDS], + C.PI_MAIN: "XEP_0045", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Multi-User Chat""") +} + +NS_MUC = 'http://jabber.org/protocol/muc' +AFFILIATIONS = ('owner', 'admin', 'member', 'none', 'outcast') +ROOM_USER_JOINED = 'ROOM_USER_JOINED' +ROOM_USER_LEFT = 'ROOM_USER_LEFT' +OCCUPANT_KEYS = ('nick', 'entity', 'affiliation', 'role') +ENTITY_TYPE_MUC = "MUC" + +CONFIG_SECTION = u'plugin muc' + +default_conf = {"default_muc": u'sat@chat.jabberfr.org'} + + +class AlreadyJoined(exceptions.ConflictError): + + def __init__(self, room): + super(AlreadyJoined, self).__init__() + self.room = room + + +class XEP_0045(object): + # TODO: handle invitations + # FIXME: this plugin need a good cleaning, join method is messy + + def __init__(self, host): + log.info(_("Plugin XEP_0045 initialization")) + self.host = host + self._sessions = memory.Sessions() + host.bridge.addMethod("mucJoin", ".plugin", in_sign='ssa{ss}s', out_sign='(bsa{sa{ss}}sss)', method=self._join, async=True) # return same arguments as mucRoomJoined + a boolean set to True is the room was already joined (first argument) + host.bridge.addMethod("mucNick", ".plugin", in_sign='sss', out_sign='', method=self._nick) + host.bridge.addMethod("mucNickGet", ".plugin", in_sign='ss', out_sign='s', method=self._getRoomNick) + host.bridge.addMethod("mucLeave", ".plugin", in_sign='ss', out_sign='', method=self._leave, async=True) + host.bridge.addMethod("mucSubject", ".plugin", in_sign='sss', out_sign='', method=self._subject) + host.bridge.addMethod("mucGetRoomsJoined", ".plugin", in_sign='s', out_sign='a(sa{sa{ss}}ss)', method=self._getRoomsJoined) + host.bridge.addMethod("mucGetUniqueRoomName", ".plugin", in_sign='ss', out_sign='s', method=self._getUniqueName) + host.bridge.addMethod("mucConfigureRoom", ".plugin", in_sign='ss', out_sign='s', method=self._configureRoom, async=True) + host.bridge.addMethod("mucGetDefaultService", ".plugin", in_sign='', out_sign='s', method=self.getDefaultMUC) + host.bridge.addMethod("mucGetService", ".plugin", in_sign='ss', out_sign='s', method=self._getMUCService, async=True) + host.bridge.addSignal("mucRoomJoined", ".plugin", signature='sa{sa{ss}}sss') # args: room_jid, occupants, user_nick, subject, profile + host.bridge.addSignal("mucRoomLeft", ".plugin", signature='ss') # args: room_jid, profile + host.bridge.addSignal("mucRoomUserChangedNick", ".plugin", signature='ssss') # args: room_jid, old_nick, new_nick, profile + host.bridge.addSignal("mucRoomNewSubject", ".plugin", signature='sss') # args: room_jid, subject, profile + self.__submit_conf_id = host.registerCallback(self._submitConfiguration, with_data=True) + self._room_join_id = host.registerCallback(self._UIRoomJoinCb, with_data=True) + host.importMenu((D_("MUC"), D_("configure")), self._configureRoomMenu, security_limit=0, help_string=D_("Configure Multi-User Chat room"), type_=C.MENU_ROOM) + try: + self.text_cmds = self.host.plugins[C.TEXT_CMDS] + except KeyError: + log.info(_(u"Text commands not available")) + else: + self.text_cmds.registerTextCommands(self) + self.text_cmds.addWhoIsCb(self._whois, 100) + + host.trigger.add("presence_available", self.presenceTrigger) + host.trigger.add("MessageReceived", self.MessageReceivedTrigger, priority=1000000) + + def profileConnected(self, client): + def assign_service(service): + client.muc_service = service + return self.getMUCService(client).addCallback(assign_service) + + def MessageReceivedTrigger(self, client, message_elt, post_treat): + if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT: + if message_elt.subject or message_elt.delay: + return False + from_jid = jid.JID(message_elt['from']) + room_jid = from_jid.userhostJID() + if room_jid in client._muc_client.joined_rooms: + room = client._muc_client.joined_rooms[room_jid] + if not room._room_ok: + log.warning(u"Received non delayed message in a room before its initialisation: {}".format(message_elt.toXml())) + room._cache.append(message_elt) + return False + else: + log.warning(u"Received groupchat message for a room which has not been joined, ignoring it: {}".format(message_elt.toXml())) + return False + return True + + def checkRoomJoined(self, client, room_jid): + """Check that given room has been joined in current session + + @param room_jid (JID): room JID + """ + if room_jid not in client._muc_client.joined_rooms: + raise exceptions.NotFound(_(u"This room has not been joined")) + + def isJoinedRoom(self, client, room_jid): + """Tell if a jid is a known and joined room + + @room_jid(jid.JID): jid of the room + """ + try: + self.checkRoomJoined(client, room_jid) + except exceptions.NotFound: + return False + else: + return True + + def _getRoomJoinedArgs(self, room, profile): + return [ + room.roomJID.userhost(), + XEP_0045._getOccupants(room), + room.nick, + room.subject, + profile + ] + + def _UIRoomJoinCb(self, data, profile): + room_jid = jid.JID(data['index']) + client = self.host.getClient(profile) + self.join(client, room_jid) + return {} + + def _passwordUICb(self, data, client, room_jid, nick): + """Called when the user has given room password (or cancelled)""" + if C.bool(data.get(C.XMLUI_DATA_CANCELLED, "false")): + log.info(u"room join for {} is cancelled".format(room_jid.userhost())) + raise failure.Failure(exceptions.CancelError(D_(u"Room joining cancelled by user"))) + password = data[xml_tools.formEscape('password')] + return client._muc_client.join(room_jid, nick, password).addCallbacks(self._joinCb, self._joinEb, (client, room_jid, nick), errbackArgs=(client, room_jid, nick, password)) + + def _showListUI(self, items, client, service): + xmlui = xml_tools.XMLUI(title=D_('Rooms in {}'.format(service.full()))) + adv_list = xmlui.changeContainer('advanced_list', columns=1, selectable='single', callback_id=self._room_join_id) + items = sorted(items, key=lambda i: i.name.lower()) + for item in items: + adv_list.setRowIndex(item.entity.full()) + xmlui.addText(item.name) + adv_list.end() + self.host.actionNew({'xmlui': xmlui.toXml()}, profile=client.profile) + + def _joinCb(self, room, client, room_jid, nick): + """Called when the user is in the requested room""" + if room.locked: + # FIXME: the current behaviour is to create an instant room + # and send the signal only when the room is unlocked + # a proper configuration management should be done + log.debug(_(u"room locked !")) + d = client._muc_client.configure(room.roomJID, {}) + d.addErrback(lambda dummy: log.error(_(u'Error while configuring the room'))) + return room.fully_joined + + def _joinEb(self, failure, client, room_jid, nick, password): + """Called when something is going wrong when joining the room""" + try: + condition = failure.value.condition + except AttributeError: + msg_suffix = '' + else: + if condition == 'conflict': + # we have a nickname conflict, we try again with "_" suffixed to current nickname + nick += '_' + return client._muc_client.join(room_jid, nick, password).addCallbacks(self._joinCb, self._joinEb, (client, room_jid, nick), errbackArgs=(client, room_jid, nick, password)) + elif condition == 'not-allowed': + # room is restricted, we need a password + password_ui = xml_tools.XMLUI("form", title=D_(u'Room {} is restricted').format(room_jid.userhost()), submit_id='') + password_ui.addText(D_("This room is restricted, please enter the password")) + password_ui.addPassword('password') + d = xml_tools.deferXMLUI(self.host, password_ui, profile=client.profile) + d.addCallback(self._passwordUICb, client, room_jid, nick) + return d + + msg_suffix = ' with condition "{}"'.format(failure.value.condition) + + mess = D_(u"Error while joining the room {room}{suffix}".format( + room = room_jid.userhost(), suffix = msg_suffix)) + log.error(mess) + xmlui = xml_tools.note(mess, D_(u"Group chat error"), level=C.XMLUI_DATA_LVL_ERROR) + self.host.actionNew({'xmlui': xmlui.toXml()}, profile=client.profile) + + @staticmethod + def _getOccupants(room): + """Get occupants of a room in a form suitable for bridge""" + return {u.nick: {k:unicode(getattr(u,k) or '') for k in OCCUPANT_KEYS} for u in room.roster.values()} + + def _getRoomsJoined(self, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + return self.getRoomsJoined(client) + + def getRoomsJoined(self, client): + """Return rooms where user is""" + result = [] + for room in client._muc_client.joined_rooms.values(): + if room._room_ok: + result.append((room.roomJID.userhost(), self._getOccupants(room), room.nick, room.subject)) + return result + + def _getRoomNick(self, room_jid_s, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + return self.getRoomNick(client, jid.JID(room_jid_s)) + + def getRoomNick(self, client, room_jid): + """return nick used in room by user + + @param room_jid (jid.JID): JID of the room + @profile_key: profile + @return: nick or empty string in case of error + @raise exceptions.Notfound: use has not joined the room + """ + self.checkRoomJoined(client, room_jid) + return client._muc_client.joined_rooms[room_jid].nick + + # FIXME: broken, to be removed ! + # def getRoomEntityNick(self, client, room_jid, entity_jid, =True): + # """Returns the nick of the given user in the room. + + # @param room (wokkel.muc.Room): the room + # @param user (jid.JID): bare JID of the user + # @param secure (bool): set to True for a secure check + # @return: unicode or None if the user didn't join the room. + # """ + # for user in room.roster.values(): + # if user.entity is not None: + # if user.entity.userhostJID() == user_jid.userhostJID(): + # return user.nick + # elif not secure: + # # FIXME: this is NOT ENOUGH to check an identity!! + # # See in which conditions user.entity could be None. + # if user.nick == user_jid.user: + # return user.nick + # return None + + # def getRoomNicksOfUsers(self, room, users=[], secure=True): + # """Returns the nicks of the given users in the room. + + # @param room (wokkel.muc.Room): the room + # @param users (list[jid.JID]): list of users + # @param secure (True): set to True for a secure check + # @return: a couple (x, y) with: + # - x (list[unicode]): nicks of the users who are in the room + # - y (list[jid.JID]): JID of the missing users. + # """ + # nicks = [] + # missing = [] + # for user in users: + # nick = self.getRoomNickOfUser(room, user, secure) + # if nick is None: + # missing.append(user) + # else: + # nicks.append(nick) + # return nicks, missing + + def _configureRoom(self, room_jid_s, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + d = self.configureRoom(client, jid.JID(room_jid_s)) + d.addCallback(lambda xmlui: xmlui.toXml()) + return d + + def _configureRoomMenu(self, menu_data, profile): + """Return room configuration form + + @param menu_data: %(menu_data)s + @param profile: %(doc_profile)s + """ + client = self.host.getClient(profile) + try: + room_jid = jid.JID(menu_data['room_jid']) + except KeyError: + log.error(_("room_jid key is not present !")) + return defer.fail(exceptions.DataError) + + def xmluiReceived(xmlui): + return {"xmlui": xmlui.toXml()} + return self.configureRoom(client, room_jid).addCallback(xmluiReceived) + + def configureRoom(self, client, room_jid): + """return the room configuration form + + @param room: jid of the room to configure + @return: configuration form as XMLUI + """ + self.checkRoomJoined(client, room_jid) + + def config2XMLUI(result): + if not result: + return "" + session_id, session_data = self._sessions.newSession(profile=client.profile) + session_data["room_jid"] = room_jid + xmlui = xml_tools.dataForm2XMLUI(result, submit_id=self.__submit_conf_id) + xmlui.session_id = session_id + return xmlui + + d = client._muc_client.getConfiguration(room_jid) + d.addCallback(config2XMLUI) + return d + + def _submitConfiguration(self, raw_data, profile): + client = self.host.getClient(profile) + try: + session_data = self._sessions.profileGet(raw_data["session_id"], profile) + except KeyError: + log.warning(D_("Session ID doesn't exist, session has probably expired.")) + _dialog = xml_tools.XMLUI('popup', title=D_('Room configuration failed')) + _dialog.addText(D_("Session ID doesn't exist, session has probably expired.")) + return defer.succeed({'xmlui': _dialog.toXml()}) + + data = xml_tools.XMLUIResult2DataFormResult(raw_data) + d = client._muc_client.configure(session_data['room_jid'], data) + _dialog = xml_tools.XMLUI('popup', title=D_('Room configuration succeed')) + _dialog.addText(D_("The new settings have been saved.")) + d.addCallback(lambda ignore: {'xmlui': _dialog.toXml()}) + del self._sessions[raw_data["session_id"]] + return d + + def isNickInRoom(self, client, room_jid, nick): + """Tell if a nick is currently present in a room""" + self.checkRoomJoined(client, room_jid) + return client._muc_client.joined_rooms[room_jid].inRoster(muc.User(nick)) + + def _getMUCService(self, jid_=None, profile=C.PROF_KEY_NONE): + client = self.host.getClient(profile) + d = self.getMUCService(client, jid_ or None) + d.addCallback(lambda service_jid: service_jid.full() if service_jid is not None else u'') + return d + + @defer.inlineCallbacks + def getMUCService(self, client, jid_=None): + """Return first found MUC service of an entity + + @param jid_: entity which may have a MUC service, or None for our own server + @return (jid.JID, None): found service jid or None + """ + if jid_ is None: + try: + muc_service = client.muc_service + except AttributeError: + pass + else: + # we have a cached value, we return it + defer.returnValue(muc_service) + services = yield self.host.findServiceEntities(client, "conference", "text", jid_) + for service in services: + if ".irc." not in service.userhost(): + # FIXME: + # This ugly hack is here to avoid an issue with openfire: the IRC gateway + # use "conference/text" identity (instead of "conference/irc") + muc_service = service + break + defer.returnValue(muc_service) + + def _getUniqueName(self, muc_service="", profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + return self.getUniqueName(client, muc_service or None).full() + + def getUniqueName(self, client, muc_service=None): + """Return unique name for a room, avoiding collision + + @param muc_service (jid.JID) : leave empty string to use the default service + @return: jid.JID (unique room bare JID) + """ + # TODO: we should use #RFC-0045 10.1.4 when available here + room_name = unicode(uuid.uuid4()) + if muc_service is None: + try: + muc_service = client.muc_service + except AttributeError: + raise exceptions.NotReady(u"Main server MUC service has not been checked yet") + if muc_service is None: + log.warning(_("No MUC service found on main server")) + raise exceptions.FeatureNotFound + + muc_service = muc_service.userhost() + return jid.JID(u"{}@{}".format(room_name, muc_service)) + + def getDefaultMUC(self): + """Return the default MUC. + + @return: unicode + """ + return self.host.memory.getConfig(CONFIG_SECTION, 'default_muc', default_conf['default_muc']) + + def _join_eb(self, failure_, client): + failure_.trap(AlreadyJoined) + room = failure_.value.room + return [True] + self._getRoomJoinedArgs(room, client.profile) + + def _join(self, room_jid_s, nick, options, profile_key=C.PROF_KEY_NONE): + """join method used by bridge + + @return (tuple): already_joined boolean + room joined arguments (see [_getRoomJoinedArgs]) + """ + client = self.host.getClient(profile_key) + if room_jid_s: + muc_service = client.muc_service + try: + room_jid = jid.JID(room_jid_s) + except (RuntimeError, jid.InvalidFormat, AttributeError): + return defer.fail(jid.InvalidFormat(_(u"Invalid room identifier: {room_id}'. Please give a room short or full identifier like 'room' or 'room@{muc_service}'.").format( + room_id=room_jid_s, + muc_service=unicode(muc_service)))) + if not room_jid.user: + room_jid.user, room_jid.host = room_jid.host, muc_service + else: + room_jid = self.getUniqueName(profile_key=client.profile) + # TODO: error management + signal in bridge + d = self.join(client, room_jid, nick, options or None) + d.addCallback(lambda room: [False] + self._getRoomJoinedArgs(room, client.profile)) + d.addErrback(self._join_eb, client) + return d + + def join(self, client, room_jid, nick=None, options=None): + if not nick: + nick = client.jid.user + if options is None: + options = {} + def _errDeferred(exc_obj=Exception, txt=u'Error while joining room'): + d = defer.Deferred() + d.errback(exc_obj(txt)) + return d + + if room_jid in client._muc_client.joined_rooms: + room = client._muc_client.joined_rooms[room_jid] + log.warning(_(u'{profile} is already in room {room_jid}').format(profile=client.profile, room_jid = room_jid.userhost())) + return defer.fail(AlreadyJoined(room)) + log.info(_(u"[{profile}] is joining room {room} with nick {nick}").format(profile=client.profile, room=room_jid.userhost(), nick=nick)) + + password = options["password"] if "password" in options else None + + return client._muc_client.join(room_jid, nick, password).addCallbacks(self._joinCb, self._joinEb, (client, room_jid, nick), errbackArgs=(client, room_jid, nick, password)) + + def _nick(self, room_jid_s, nick, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + return self.nick(client, jid.JID(room_jid_s), nick) + + def nick(self, client, room_jid, nick): + """Change nickname in a room""" + self.checkRoomJoined(client, room_jid) + return client._muc_client.nick(room_jid, nick) + + def _leave(self, room_jid, profile_key): + client = self.host.getClient(profile_key) + return self.leave(client, jid.JID(room_jid)) + + def leave(self, client, room_jid): + self.checkRoomJoined(client, room_jid) + return client._muc_client.leave(room_jid) + + def _subject(self, room_jid_s, new_subject, profile_key): + client = self.host.getClient(profile_key) + return self.subject(client, jid.JID(room_jid_s), new_subject) + + def subject(self, client, room_jid, subject): + self.checkRoomJoined(client, room_jid) + return client._muc_client.subject(room_jid, subject) + + def getHandler(self, client): + # create a MUC client and associate it with profile' session + muc_client = client._muc_client = SatMUCClient(self) + return muc_client + + def kick(self, client, nick, room_jid, options=None): + """ + Kick a participant from the room + @param nick (str): nick of the user to kick + @param room_jid_s (JID): jid of the room + @param options (dict): attribute with extra info (reason, password) as in #XEP-0045 + """ + if options is None: + options = {} + self.checkRoomJoined(client, room_jid) + return client._muc_client.kick(room_jid, nick, reason=options.get('reason', None)) + + def ban(self, client, entity_jid, room_jid, options=None): + """Ban an entity from the room + + @param entity_jid (JID): bare jid of the entity to be banned + @param room_jid (JID): jid of the room + @param options: attribute with extra info (reason, password) as in #XEP-0045 + """ + self.checkRoomJoined(client, room_jid) + if options is None: + options = {} + assert not entity_jid.resource + assert not room_jid.resource + return client._muc_client.ban(room_jid, entity_jid, reason=options.get('reason', None)) + + def affiliate(self, client, entity_jid, room_jid, options): + """Change the affiliation of an entity + + @param entity_jid (JID): bare jid of the entity + @param room_jid_s (JID): jid of the room + @param options: attribute with extra info (reason, nick) as in #XEP-0045 + """ + self.checkRoomJoined(client, room_jid) + assert not entity_jid.resource + assert not room_jid.resource + assert 'affiliation' in options + # TODO: handles reason and nick + return client._muc_client.modifyAffiliationList(room_jid, [entity_jid], options['affiliation']) + + # Text commands # + + def cmd_nick(self, client, mess_data): + """change nickname + + @command (group): new_nick + - new_nick: new nick to use + """ + nick = mess_data["unparsed"].strip() + if nick: + room = mess_data["to"] + self.nick(client, room, nick) + + return False + + def cmd_join(self, client, mess_data): + """join a new room + + @command (all): JID + - JID: room to join (on the same service if full jid is not specified) + """ + if mess_data["unparsed"].strip(): + room_jid = self.text_cmds.getRoomJID(mess_data["unparsed"].strip(), mess_data["to"].host) + nick = (self.getRoomNick(client, room_jid) or + client.jid.user) + self.join(client, room_jid, nick, {}) + + return False + + def cmd_leave(self, client, mess_data): + """quit a room + + @command (group): [ROOM_JID] + - ROOM_JID: jid of the room to live (current room if not specified) + """ + if mess_data["unparsed"].strip(): + room = self.text_cmds.getRoomJID(mess_data["unparsed"].strip(), mess_data["to"].host) + else: + room = mess_data["to"] + + self.leave(client, room) + + return False + + def cmd_part(self, client, mess_data): + """just a synonym of /leave + + @command (group): [ROOM_JID] + - ROOM_JID: jid of the room to live (current room if not specified) + """ + return self.cmd_leave(client, mess_data) + + def cmd_kick(self, client, mess_data): + """kick a room member + + @command (group): ROOM_NICK + - ROOM_NICK: the nick of the person to kick + """ + options = mess_data["unparsed"].strip().split() + try: + nick = options[0] + assert self.isNickInRoom(client, mess_data["to"], nick) + except (IndexError, AssertionError): + feedback = _(u"You must provide a member's nick to kick.") + self.text_cmds.feedBack(client, feedback, mess_data) + return False + + d = self.kick(client, nick, mess_data["to"], {} if len(options) == 1 else {'reason': options[1]}) + + def cb(dummy): + feedback_msg = _(u'You have kicked {}').format(nick) + if len(options) > 1: + feedback_msg += _(u' for the following reason: {}').format(options[1]) + self.text_cmds.feedBack(client, feedback_msg, mess_data) + return True + d.addCallback(cb) + return d + + def cmd_ban(self, client, mess_data): + """ban an entity from the room + + @command (group): (JID) [reason] + - JID: the JID of the entity to ban + - reason: the reason why this entity is being banned + """ + options = mess_data["unparsed"].strip().split() + try: + jid_s = options[0] + entity_jid = jid.JID(jid_s).userhostJID() + assert(entity_jid.user) + assert(entity_jid.host) + except (RuntimeError, jid.InvalidFormat, AttributeError, IndexError, AssertionError): + feedback = _(u"You must provide a valid JID to ban, like in '/ban contact@example.net'") + self.text_cmds.feedBack(client, feedback, mess_data) + return False + + d = self.ban(client, entity_jid, mess_data["to"], {} if len(options) == 1 else {'reason': options[1]}) + + def cb(dummy): + feedback_msg = _(u'You have banned {}').format(entity_jid) + if len(options) > 1: + feedback_msg += _(u' for the following reason: {}').format(options[1]) + self.text_cmds.feedBack(client, feedback_msg, mess_data) + return True + d.addCallback(cb) + return d + + def cmd_affiliate(self, client, mess_data): + """affiliate an entity to the room + + @command (group): (JID) [owner|admin|member|none|outcast] + - JID: the JID of the entity to affiliate + - owner: grant owner privileges + - admin: grant admin privileges + - member: grant member privileges + - none: reset entity privileges + - outcast: ban entity + """ + options = mess_data["unparsed"].strip().split() + try: + jid_s = options[0] + entity_jid = jid.JID(jid_s).userhostJID() + assert(entity_jid.user) + assert(entity_jid.host) + except (RuntimeError, jid.InvalidFormat, AttributeError, IndexError, AssertionError): + feedback = _(u"You must provide a valid JID to affiliate, like in '/affiliate contact@example.net member'") + self.text_cmds.feedBack(client, feedback, mess_data) + return False + + affiliation = options[1] if len(options) > 1 else 'none' + if affiliation not in AFFILIATIONS: + feedback = _(u"You must provide a valid affiliation: %s") % ' '.join(AFFILIATIONS) + self.text_cmds.feedBack(client, feedback, mess_data) + return False + + d = self.affiliate(client, entity_jid, mess_data["to"], {'affiliation': affiliation}) + + def cb(dummy): + feedback_msg = _(u'New affiliation for %(entity)s: %(affiliation)s').format(entity=entity_jid, affiliation=affiliation) + self.text_cmds.feedBack(client, feedback_msg, mess_data) + return True + d.addCallback(cb) + return d + + def cmd_title(self, client, mess_data): + """change room's subject + + @command (group): title + - title: new room subject + """ + subject = mess_data["unparsed"].strip() + + if subject: + room = mess_data["to"] + self.subject(client, room, subject) + + return False + + def cmd_topic(self, client, mess_data): + """just a synonym of /title + + @command (group): title + - title: new room subject + """ + return self.cmd_title(client, mess_data) + + def cmd_list(self, client, mess_data): + """list available rooms in a muc server + + @command (all): [MUC_SERVICE] + - MUC_SERVICE: service to request + empty value will request room's service for a room, + or user's server default MUC service in a one2one chat + """ + unparsed = mess_data["unparsed"].strip() + try: + service = jid.JID(unparsed) + except RuntimeError: + if mess_data['type'] == C.MESS_TYPE_GROUPCHAT: + room_jid = mess_data["to"] + service = jid.JID(room_jid.host) + elif client.muc_service is not None: + service = client.muc_service + else: + msg = D_(u"No known default MUC service".format(unparsed)) + self.text_cmds.feedBack(client, msg, mess_data) + return False + except jid.InvalidFormat: + msg = D_(u"{} is not a valid JID!".format(unparsed)) + self.text_cmds.feedBack(client, msg, mess_data) + return False + d = self.host.getDiscoItems(client, service) + d.addCallback(self._showListUI, client, service) + + return False + + def _whois(self, client, whois_msg, mess_data, target_jid): + """ Add MUC user information to whois """ + if mess_data['type'] != "groupchat": + return + if target_jid.userhostJID() not in client._muc_client.joined_rooms: + log.warning(_("This room has not been joined")) + return + if not target_jid.resource: + return + user = client._muc_client.joined_rooms[target_jid.userhostJID()].getUser(target_jid.resource) + whois_msg.append(_("Nickname: %s") % user.nick) + if user.entity: + whois_msg.append(_("Entity: %s") % user.entity) + if user.affiliation != 'none': + whois_msg.append(_("Affiliation: %s") % user.affiliation) + if user.role != 'none': + whois_msg.append(_("Role: %s") % user.role) + if user.status: + whois_msg.append(_("Status: %s") % user.status) + if user.show: + whois_msg.append(_("Show: %s") % user.show) + + def presenceTrigger(self, presence_elt, client): + # XXX: shouldn't it be done by the server ?!! + muc_client = client._muc_client + for room_jid, room in muc_client.joined_rooms.iteritems(): + elt = copy.deepcopy(presence_elt) + elt['to'] = room_jid.userhost() + '/' + room.nick + client.presence.send(elt) + return True + + +class SatMUCClient(muc.MUCClient): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + muc.MUCClient.__init__(self) + self.rec_subjects = {} + self._changing_nicks = set() # used to keep trace of who is changing nick, + # and to discard userJoinedRoom signal in this case + print "init SatMUCClient OK" + + @property + def joined_rooms(self): + return self._rooms + + def _addRoom(self, room): + super(SatMUCClient, self)._addRoom(room) + room._roster_ok = False # True when occupants list has been fully received + room._room_ok = None # False when roster, history and subject are available + # True when new messages are saved to database + room._history_d = defer.Deferred() # used to send bridge signal once backlog are written in history + room._history_d.callback(None) + # FIXME: check if history_d is not redundant with fully_joined + room.fully_joined = defer.Deferred() # called when everything is OK + room._cache = [] + + def _gotLastDbHistory(self, mess_data_list, room_jid, nick, password): + if mess_data_list: + timestamp = mess_data_list[0][1] + # we use seconds since last message to get backlog without duplicates + # and we remove 1 second to avoid getting the last message again + seconds = int(time.time() - timestamp) - 1 + else: + seconds = None + d = super(SatMUCClient, self).join(room_jid, nick, muc.HistoryOptions(seconds=seconds), password) + return d + + def join(self, room_jid, nick, password=None): + d = self.host.memory.historyGet(self.parent.jid.userhostJID(), room_jid, 1, True, profile=self.parent.profile) + d.addCallback(self._gotLastDbHistory, room_jid, nick, password) + return d + + ## presence/roster ## + + def availableReceived(self, presence): + """ + Available presence was received. + """ + # XXX: we override MUCClient.availableReceived to fix bugs + # (affiliation and role are not set) + + room, user = self._getRoomUser(presence) + + if room is None: + return + + if user is None: + nick = presence.sender.resource + user = muc.User(nick, presence.entity) + + # Update user data + user.role = presence.role + user.affiliation = presence.affiliation + user.status = presence.status + user.show = presence.show + + if room.inRoster(user): + self.userUpdatedStatus(room, user, presence.show, presence.status) + else: + room.addUser(user) + self.userJoinedRoom(room, user) + + def unavailableReceived(self, presence): + # XXX: we override this method to manage nickname change + """ + Unavailable presence was received. + + If this was received from a MUC room occupant JID, that occupant has + left the room. + """ + room, user = self._getRoomUser(presence) + + if room is None or user is None: + return + + room.removeUser(user) + + if muc.STATUS_CODE.NEW_NICK in presence.mucStatuses: + self._changing_nicks.add(presence.nick) + self.userChangedNick(room, user, presence.nick) + else: + self._changing_nicks.discard(presence.nick) + self.userLeftRoom(room, user) + + def userJoinedRoom(self, room, user): + if user.nick == room.nick: + # we have received our own nick, this mean that the full room roster was received + room._roster_ok = True + log.debug(u"room {room} joined with nick {nick}".format(room=room.occupantJID.userhost(), nick=user.nick)) + # We set type so we don't have use a deferred with disco to check entity type + self.host.memory.updateEntityData(room.roomJID, C.ENTITY_TYPE, ENTITY_TYPE_MUC, profile_key=self.parent.profile) + elif not room._room_ok: + log.warning(u"Received user presence data in a room before its initialisation (and after our own presence)," + "this is not standard! Ignoring it: {} ({})".format( + room.roomJID.userhost(), + user.nick)) + return + elif room._roster_ok: + try: + self._changing_nicks.remove(user.nick) + except KeyError: + # this is a new user + log.debug(_(u"user {nick} has joined room {room_id}").format(nick=user.nick, room_id=room.occupantJID.userhost())) + if not self.host.trigger.point("MUC user joined", room, user, self.parent.profile): + return + + extra = {'info_type': ROOM_USER_JOINED, + 'user_affiliation': user.affiliation, + 'user_role': user.role, + 'user_nick': user.nick + } + if user.entity is not None: + extra['user_entity'] = user.entity.full() + mess_data = { # dict is similar to the one used in client.onMessage + "from": room.roomJID, + "to": self.parent.jid, + "uid": unicode(uuid.uuid4()), + "message": {'': D_(u"=> {} has joined the room").format(user.nick)}, + "subject": {}, + "type": C.MESS_TYPE_INFO, + "extra": extra, + "timestamp": time.time(), + } + self.parent.messageAddToHistory(mess_data) + self.parent.messageSendToBridge(mess_data) + + + def userLeftRoom(self, room, user): + if not self.host.trigger.point("MUC user left", room, user, self.parent.profile): + return + if user.nick == room.nick: + # we left the room + room_jid_s = room.roomJID.userhost() + log.info(_(u"Room ({room}) left ({profile})").format( + room = room_jid_s, profile = self.parent.profile)) + self.host.memory.delEntityCache(room.roomJID, profile_key=self.parent.profile) + self.host.bridge.mucRoomLeft(room.roomJID.userhost(), self.parent.profile) + elif not room._room_ok: + log.warning(u"Received user presence data in a room before its initialisation (and after our own presence)," + "this is not standard! Ignoring it: {} ({})".format( + room.roomJID.userhost(), + user.nick)) + return + else: + log.debug(_(u"user {nick} left room {room_id}").format(nick=user.nick, room_id=room.occupantJID.userhost())) + extra = {'info_type': ROOM_USER_LEFT, + 'user_affiliation': user.affiliation, + 'user_role': user.role, + 'user_nick': user.nick + } + if user.entity is not None: + extra['user_entity'] = user.entity.full() + mess_data = { # dict is similar to the one used in client.onMessage + "from": room.roomJID, + "to": self.parent.jid, + "uid": unicode(uuid.uuid4()), + "message": {'': D_(u"<= {} has left the room").format(user.nick)}, + "subject": {}, + "type": C.MESS_TYPE_INFO, + "extra": extra, + "timestamp": time.time(), + } + self.parent.messageAddToHistory(mess_data) + self.parent.messageSendToBridge(mess_data) + + def userChangedNick(self, room, user, new_nick): + self.host.bridge.mucRoomUserChangedNick(room.roomJID.userhost(), user.nick, new_nick, self.parent.profile) + + def userUpdatedStatus(self, room, user, show, status): + self.host.bridge.presenceUpdate(room.roomJID.userhost() + '/' + user.nick, show or '', 0, {C.PRESENCE_STATUSES_DEFAULT: status or ''}, self.parent.profile) + + ## messages ## + + def receivedGroupChat(self, room, user, body): + log.debug(u'receivedGroupChat: room=%s user=%s body=%s' % (room.roomJID.full(), user, body)) + + def _addToHistory(self, dummy, user, message): + # we check if message is not in history + # and raise ConflictError else + stamp = message.delay.stamp.astimezone(tzutc()).timetuple() + timestamp = float(calendar.timegm(stamp)) + data = { # dict is similar to the one used in client.onMessage + "from": message.sender, + "to": message.recipient, + "uid": unicode(uuid.uuid4()), + "type": C.MESS_TYPE_GROUPCHAT, + "extra": {}, + "timestamp": timestamp, + "received_timestamp": unicode(time.time()), + } + # FIXME: message and subject don't handle xml:lang + data['message'] = {'': message.body} if message.body is not None else {} + data['subject'] = {'': message.subject} if message.subject is not None else {} + + if data['message'] or data['subject']: + return self.host.memory.addToHistory(self.parent, data) + else: + return defer.succeed(None) + + def _addToHistoryEb(self, failure): + failure.trap(exceptions.CancelError) + + def receivedHistory(self, room, user, message): + """Called when history (backlog) message are received + + we check if message is not already in our history + and add it if needed + @param room(muc.Room): room instance + @param user(muc.User, None): the user that sent the message + None if the message come from the room + @param message(muc.GroupChat): the parsed message + """ + room._history_d.addCallback(self._addToHistory, user, message) + room._history_d.addErrback(self._addToHistoryEb) + + ## subject ## + + def groupChatReceived(self, message): + """ + A group chat message has been received from a MUC room. + + There are a few event methods that may get called here. + L{receivedGroupChat}, L{receivedSubject} or L{receivedHistory}. + """ + # We override this method to fix subject handling + # FIXME: remove this merge fixed upstream + room, user = self._getRoomUser(message) + + if room is None: + return + + if message.subject is not None: + self.receivedSubject(room, user, message.subject) + elif message.delay is None: + self.receivedGroupChat(room, user, message) + else: + self.receivedHistory(room, user, message) + + def subject(self, room, subject): + return muc.MUCClientProtocol.subject(self, room, subject) + + def _historyCb(self, dummy, room): + args = self.plugin_parent._getRoomJoinedArgs(room, self.parent.profile) + self.host.bridge.mucRoomJoined(*args) + del room._history_d + cache = room._cache + del room._cache + room._room_ok = True + for elem in cache: + self.parent.xmlstream.dispatch(elem) + + def _historyEb(self, failure_, room): + log.error(u"Error while managing history: {}".format(failure_)) + self._historyCb(None, room) + + def receivedSubject(self, room, user, subject): + # when subject is received, we know that we have whole roster and history + # cf. http://xmpp.org/extensions/xep-0045.html#enter-subject + room.subject = subject # FIXME: subject doesn't handle xml:lang + self.rec_subjects[room.roomJID.userhost()] = (room.roomJID.userhost(), subject) + if room._room_ok is None: + # this is the first subject we receive + # that mean that we have received everything we need + room._room_ok = False + room._history_d.addCallbacks(self._historyCb, self._historyEb, [room], errbackArgs=[room]) + room.fully_joined.callback(room) + else: + # the subject has been changed + log.debug(_(u"New subject for room ({room_id}): {subject}").format(room_id = room.roomJID.full(), subject = subject)) + self.host.bridge.mucRoomNewSubject(room.roomJID.userhost(), subject, self.parent.profile) + + ## disco ## + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_MUC)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + # TODO: manage room queries ? Bad for privacy, must be disabled by default + # see XEP-0045 § 6.7 + return []